Jun 28 - Implement payment functions (Stripe)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
app/billing/__init__.py
|
||||
-----------------------
|
||||
MT-8 Stripe billing package. Blueprint registered at /billing/.
|
||||
|
||||
All routes are CSRF-exempt (billing.bp is exempted individually in
|
||||
app/__init__.py). The webhook route specifically requires raw POST body
|
||||
access — CSRF tokens are never sent by Stripe.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('billing', __name__, url_prefix='/billing')
|
||||
|
||||
from app.billing import routes # noqa: F401, E402 — registers routes on bp
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
app/billing/emails.py
|
||||
---------------------
|
||||
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
|
||||
|
||||
Pattern mirrors app/utils/notifications.py: captures the app object before
|
||||
spawning the thread so the mail context is valid inside the worker.
|
||||
|
||||
Public API:
|
||||
send_billing_email(to_addr, event_type, context_dict)
|
||||
event_type in ('payment_failed', 'trial_ending', 'subscription_cancelled')
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from flask import current_app
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Email templates ────────────────────────────────────────────────────────────
|
||||
|
||||
_SUBJECTS = {
|
||||
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
||||
'trial_ending': 'Your JQC free trial ends in 3 days',
|
||||
'subscription_cancelled': 'Your JQC subscription has been cancelled',
|
||||
}
|
||||
|
||||
_BODIES = {
|
||||
'payment_failed': """\
|
||||
Hi,
|
||||
|
||||
We were unable to process your most recent payment for your JQC subscription.
|
||||
|
||||
Please update your payment method to avoid interruption to your service:
|
||||
{portal_url}
|
||||
|
||||
If you have any questions, please contact support.
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
|
||||
'trial_ending': """\
|
||||
Hi,
|
||||
|
||||
Your JQC free trial will end on {trial_ends_at}. After that date, you will
|
||||
need an active subscription to continue using the service.
|
||||
|
||||
Subscribe now to keep your workspace active:
|
||||
{subscribe_url}
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
|
||||
'subscription_cancelled': """\
|
||||
Hi,
|
||||
|
||||
Your JQC subscription has been cancelled and your workspace access has
|
||||
been suspended. You can reactivate your subscription at any time:
|
||||
{portal_url}
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
||||
"""Send a billing lifecycle email in a background thread.
|
||||
|
||||
Args:
|
||||
to_addr: Recipient email address (tenant admin or billing contact).
|
||||
event_type: Key in _SUBJECTS / _BODIES.
|
||||
context_dict: Variables substituted into the body template.
|
||||
"""
|
||||
if not to_addr:
|
||||
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
|
||||
return
|
||||
|
||||
subject = _SUBJECTS.get(event_type, 'JQC Billing Notification')
|
||||
body_template = _BODIES.get(event_type, '')
|
||||
try:
|
||||
body = body_template.format(**context_dict)
|
||||
except KeyError as exc:
|
||||
logger.error('BILLING EMAIL | template_error | event=%s | missing_key=%s', event_type, exc)
|
||||
body = body_template # send with unfilled placeholders rather than crashing
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
try:
|
||||
with app.app_context():
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[to_addr],
|
||||
body=body,
|
||||
)
|
||||
mail.send(msg)
|
||||
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
|
||||
except Exception as exc:
|
||||
logger.error('BILLING EMAIL | send_failed | event=%s | to=%s | err=%s',
|
||||
event_type, to_addr, exc)
|
||||
|
||||
thread = threading.Thread(target=_send, daemon=True)
|
||||
thread.start()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
app/billing/routes.py
|
||||
---------------------
|
||||
MT-8 Stripe billing routes.
|
||||
|
||||
Routes:
|
||||
GET /billing/subscribe → create Stripe Checkout session → redirect
|
||||
GET /billing/portal → create Stripe Customer Portal session → redirect
|
||||
POST /billing/webhook → verify Stripe signature → handle event
|
||||
GET /billing/suspended → suspension landing page (no auth required)
|
||||
GET /billing/success → post-checkout success landing
|
||||
GET /billing/cancel → post-checkout cancel (redirects to plan page)
|
||||
|
||||
All routes are CSRF-exempt (billing.bp is exempted in app/__init__.py).
|
||||
The webhook route is the primary reason — Stripe cannot send CSRF tokens.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
current_app, redirect, url_for, render_template,
|
||||
request, flash, g, abort,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app.billing import bp
|
||||
from app.utils.decorators import admin_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _billing_enabled():
|
||||
return current_app.config.get('BILLING_ENABLED', False)
|
||||
|
||||
|
||||
def _mt_enabled():
|
||||
return current_app.config.get('MULTI_TENANT_ENABLED', False)
|
||||
|
||||
|
||||
# ── Subscribe: initiate Stripe Checkout ───────────────────────────────────────
|
||||
|
||||
@bp.route('/subscribe')
|
||||
@login_required
|
||||
@admin_required
|
||||
def subscribe():
|
||||
if not _billing_enabled():
|
||||
flash('Billing is not enabled on this deployment.', 'info')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
if not _mt_enabled():
|
||||
flash('Billing is only available in multi-tenant mode.', 'info')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
if tenant is None:
|
||||
abort(400)
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant as ControlTenant
|
||||
from app.billing.stripe_client import create_stripe_customer, create_checkout_session
|
||||
|
||||
with control_session() as s:
|
||||
t = s.get(ControlTenant, tenant.id)
|
||||
if t is None:
|
||||
flash('Tenant not found.', 'danger')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
plan = t.plan
|
||||
if plan is None or not plan.stripe_price_id:
|
||||
flash(
|
||||
'Your current plan does not have a Stripe price configured. '
|
||||
'Please contact support to set up billing.',
|
||||
'warning',
|
||||
)
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
# Get or create Stripe customer. Write back immediately within the
|
||||
# control_session so the customer_id is persisted before the redirect.
|
||||
if not t.stripe_customer_id:
|
||||
try:
|
||||
customer = create_stripe_customer(
|
||||
email=current_user.email,
|
||||
name=current_user.display_name,
|
||||
tenant_id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
)
|
||||
t.stripe_customer_id = customer['id']
|
||||
t.billing_email = current_user.email
|
||||
except Exception as exc:
|
||||
logger.error('BILLING | create_customer_failed | tenant=%s err=%s', tenant.slug, exc)
|
||||
flash('Could not connect to Stripe. Please try again or contact support.', 'danger')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
stripe_customer_id = t.stripe_customer_id
|
||||
price_id = plan.stripe_price_id
|
||||
|
||||
# Create Checkout Session AFTER the control_session commits customer_id.
|
||||
base_url = (current_app.config.get('APP_BASE_URL') or request.host_url).rstrip('/')
|
||||
try:
|
||||
session = create_checkout_session(
|
||||
customer_id=stripe_customer_id,
|
||||
price_id=price_id,
|
||||
tenant_id=tenant.id,
|
||||
success_url=f'{base_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}/billing/cancel',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('BILLING | create_checkout_failed | tenant=%s err=%s', tenant.slug, exc)
|
||||
flash('Could not create a Stripe Checkout session. Please try again.', 'danger')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
return redirect(session['url'], 303)
|
||||
|
||||
|
||||
# ── Portal: Stripe Customer Portal for managing subscriptions ─────────────────
|
||||
|
||||
@bp.route('/portal')
|
||||
@login_required
|
||||
@admin_required
|
||||
def portal():
|
||||
if not _billing_enabled():
|
||||
flash('Billing is not enabled on this deployment.', 'info')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
if tenant is None:
|
||||
abort(400)
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant as ControlTenant
|
||||
from app.billing.stripe_client import create_portal_session
|
||||
|
||||
with control_session() as s:
|
||||
t = s.get(ControlTenant, tenant.id)
|
||||
if t is None or not t.stripe_customer_id:
|
||||
flash('No active subscription found. Please subscribe first.', 'warning')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
stripe_customer_id = t.stripe_customer_id
|
||||
|
||||
base_url = (current_app.config.get('APP_BASE_URL') or request.host_url).rstrip('/')
|
||||
return_url = f'{base_url}/settings/plan'
|
||||
|
||||
try:
|
||||
session = create_portal_session(
|
||||
customer_id=stripe_customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('BILLING | create_portal_failed | tenant=%s err=%s', tenant.slug, exc)
|
||||
flash('Could not open the billing portal. Please try again.', 'danger')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
|
||||
return redirect(session['url'], 303)
|
||||
|
||||
|
||||
# ── Webhook: receives all Stripe events ───────────────────────────────────────
|
||||
|
||||
@bp.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Stripe webhook endpoint. Verifies signature then dispatches to webhooks.py."""
|
||||
webhook_secret = current_app.config.get('STRIPE_WEBHOOK_SECRET')
|
||||
if not webhook_secret:
|
||||
logger.error('BILLING WEBHOOK | STRIPE_WEBHOOK_SECRET not configured')
|
||||
return {'error': 'Webhook secret not configured'}, 500
|
||||
|
||||
payload = request.get_data()
|
||||
sig_header = request.headers.get('Stripe-Signature', '')
|
||||
|
||||
try:
|
||||
import stripe
|
||||
stripe.api_key = current_app.config.get('STRIPE_SECRET_KEY', '')
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
except ValueError:
|
||||
logger.warning('BILLING WEBHOOK | invalid_payload')
|
||||
return {'error': 'Invalid payload'}, 400
|
||||
except stripe.error.SignatureVerificationError:
|
||||
logger.warning('BILLING WEBHOOK | invalid_signature')
|
||||
return {'error': 'Invalid signature'}, 400
|
||||
|
||||
from app.billing.webhooks import handle_event
|
||||
handle_event(event)
|
||||
|
||||
return {'ok': True}, 200
|
||||
|
||||
|
||||
# ── Suspended: shown when subscription is cancelled ───────────────────────────
|
||||
|
||||
@bp.route('/suspended')
|
||||
def suspended():
|
||||
"""Billing-suspended landing page. No authentication required."""
|
||||
return render_template('billing/suspended.html')
|
||||
|
||||
|
||||
# ── Post-checkout success / cancel ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/success')
|
||||
@login_required
|
||||
def success():
|
||||
flash('Your subscription is now active. Welcome aboard!', 'success')
|
||||
return render_template('billing/success.html')
|
||||
|
||||
|
||||
@bp.route('/cancel')
|
||||
@login_required
|
||||
def cancel():
|
||||
flash('Checkout was cancelled. Your subscription has not changed.', 'info')
|
||||
return redirect(url_for('tenant_settings.plan'))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
app/billing/stripe_client.py
|
||||
-----------------------------
|
||||
Thin wrappers around the Stripe SDK. No Flask imports — usable from routes,
|
||||
webhooks, and background tasks alike.
|
||||
|
||||
All functions raise RuntimeError with a clear message if STRIPE_SECRET_KEY is
|
||||
absent, rather than producing cryptic Stripe SDK authentication errors.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_stripe():
|
||||
"""Import and initialise Stripe with the secret key. Raises if key is missing."""
|
||||
import stripe as _stripe
|
||||
key = os.environ.get('STRIPE_SECRET_KEY')
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
'STRIPE_SECRET_KEY is not set. '
|
||||
'Add it to your environment before enabling BILLING_ENABLED=true.'
|
||||
)
|
||||
_stripe.api_key = key
|
||||
return _stripe
|
||||
|
||||
|
||||
def create_stripe_customer(*, email: str, name: str, tenant_id: int, slug: str) -> dict:
|
||||
"""Create a Stripe Customer and return the customer dict.
|
||||
|
||||
Stores tenant_id and slug in Stripe metadata for webhook reverse-lookup.
|
||||
"""
|
||||
stripe = _get_stripe()
|
||||
customer = stripe.Customer.create(
|
||||
email=email,
|
||||
name=name,
|
||||
metadata={'tenant_id': str(tenant_id), 'slug': slug},
|
||||
)
|
||||
logger.info('BILLING | stripe_customer_created | tenant=%s customer=%s', slug, customer['id'])
|
||||
return customer
|
||||
|
||||
|
||||
def create_checkout_session(
|
||||
*,
|
||||
customer_id: str,
|
||||
price_id: str,
|
||||
tenant_id: int,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> dict:
|
||||
"""Create a Stripe Checkout Session for a subscription and return the session dict.
|
||||
|
||||
`success_url` should contain `{CHECKOUT_SESSION_ID}` which Stripe replaces
|
||||
with the actual session ID on redirect (pass as `{{CHECKOUT_SESSION_ID}}`
|
||||
inside an f-string to produce the literal braces).
|
||||
"""
|
||||
stripe = _get_stripe()
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
mode='subscription',
|
||||
line_items=[{'price': price_id, 'quantity': 1}],
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={'tenant_id': str(tenant_id)},
|
||||
allow_promotion_codes=True,
|
||||
)
|
||||
logger.info('BILLING | checkout_session_created | tenant_id=%s session=%s', tenant_id, session['id'])
|
||||
return session
|
||||
|
||||
|
||||
def create_portal_session(*, customer_id: str, return_url: str) -> dict:
|
||||
"""Create a Stripe Billing Portal Session and return the session dict."""
|
||||
stripe = _get_stripe()
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
logger.info('BILLING | portal_session_created | customer=%s', customer_id)
|
||||
return session
|
||||
|
||||
|
||||
def retrieve_subscription(subscription_id: str) -> dict:
|
||||
"""Retrieve a Stripe Subscription object."""
|
||||
stripe = _get_stripe()
|
||||
return stripe.Subscription.retrieve(subscription_id)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
app/billing/webhooks.py
|
||||
------------------------
|
||||
Stripe webhook event dispatcher. Receives a verified stripe.Event object
|
||||
(signature already checked by the route) and mutates the control DB accordingly.
|
||||
|
||||
Each event handler is self-contained and catches its own exceptions so that
|
||||
one malformed event cannot abort processing of subsequent events in the same
|
||||
delivery batch.
|
||||
|
||||
Public API:
|
||||
handle_event(event) -> None
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps Stripe subscription status strings to our internal subscription_status values.
|
||||
_STRIPE_STATUS_MAP = {
|
||||
'trialing': 'trial',
|
||||
'active': 'active',
|
||||
'past_due': 'past_due',
|
||||
'canceled': 'cancelled', # Stripe uses single 'l'
|
||||
'cancelled': 'cancelled',
|
||||
'incomplete': 'past_due',
|
||||
'incomplete_expired': 'cancelled',
|
||||
'unpaid': 'past_due',
|
||||
'paused': 'past_due',
|
||||
}
|
||||
|
||||
|
||||
def _ts_to_dt(ts) -> datetime | None:
|
||||
"""Convert a Unix timestamp (int or None) to a naive UTC datetime."""
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _lookup_tenant_by_customer(stripe_customer_id: str):
|
||||
"""Return the Tenant ORM object (within an open session) or None."""
|
||||
from control.models import Tenant
|
||||
return None # placeholder — caller must query within a control_session
|
||||
|
||||
|
||||
def _get_base_url() -> str:
|
||||
from flask import current_app
|
||||
return (current_app.config.get('APP_BASE_URL') or '').rstrip('/')
|
||||
|
||||
|
||||
def handle_event(event) -> None:
|
||||
"""Dispatch a verified Stripe event to the appropriate handler."""
|
||||
event_type = event['type']
|
||||
data_obj = event['data']['object']
|
||||
|
||||
handlers = {
|
||||
'checkout.session.completed': _on_checkout_completed,
|
||||
'customer.subscription.updated': _on_subscription_updated,
|
||||
'customer.subscription.deleted': _on_subscription_deleted,
|
||||
'invoice.payment_succeeded': _on_payment_succeeded,
|
||||
'invoice.payment_failed': _on_payment_failed,
|
||||
'customer.subscription.trial_will_end': _on_trial_will_end,
|
||||
}
|
||||
|
||||
handler = handlers.get(event_type)
|
||||
if handler is None:
|
||||
logger.debug('BILLING WEBHOOK | unhandled | type=%s', event_type)
|
||||
return
|
||||
|
||||
try:
|
||||
handler(data_obj)
|
||||
except Exception as exc:
|
||||
logger.error('BILLING WEBHOOK | handler_error | type=%s | err=%s', event_type, exc, exc_info=True)
|
||||
|
||||
|
||||
# ── Individual event handlers ──────────────────────────────────────────────────
|
||||
|
||||
def _on_checkout_completed(obj):
|
||||
"""checkout.session.completed — customer completed Stripe Checkout."""
|
||||
customer_id = obj.get('customer')
|
||||
subscription_id = obj.get('subscription')
|
||||
|
||||
if not customer_id or not subscription_id:
|
||||
logger.warning('BILLING | checkout_completed_missing_fields | obj=%s', obj.get('id'))
|
||||
return
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant, TenantAudit
|
||||
from control.time_utils import now_eastern
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
logger.warning('BILLING | checkout_completed_no_tenant | customer=%s', customer_id)
|
||||
return
|
||||
|
||||
t.stripe_subscription_id = subscription_id
|
||||
t.subscription_status = 'active'
|
||||
t.trial_ends_at = None # trial period ended at checkout
|
||||
|
||||
s.add(TenantAudit(
|
||||
action='billing_checkout_completed',
|
||||
tenant_id=t.id,
|
||||
details=f'subscription_id={subscription_id}',
|
||||
))
|
||||
|
||||
logger.info('BILLING | checkout_completed | tenant_id=%s sub=%s', t.id, subscription_id)
|
||||
|
||||
|
||||
def _on_subscription_updated(obj):
|
||||
"""customer.subscription.updated — sync status from Stripe."""
|
||||
customer_id = obj.get('customer')
|
||||
subscription_id = obj.get('id')
|
||||
stripe_status = obj.get('status', '')
|
||||
period_end_ts = obj.get('current_period_end')
|
||||
trial_end_ts = obj.get('trial_end')
|
||||
|
||||
our_status = _STRIPE_STATUS_MAP.get(stripe_status)
|
||||
if our_status is None:
|
||||
logger.warning('BILLING | subscription_updated_unknown_status | status=%s', stripe_status)
|
||||
return
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant, TenantAudit
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
logger.warning('BILLING | subscription_updated_no_tenant | customer=%s', customer_id)
|
||||
return
|
||||
|
||||
old_status = t.subscription_status
|
||||
t.subscription_status = our_status
|
||||
t.stripe_subscription_id = subscription_id
|
||||
t.current_period_end = _ts_to_dt(period_end_ts)
|
||||
if trial_end_ts:
|
||||
t.trial_ends_at = _ts_to_dt(trial_end_ts)
|
||||
|
||||
s.add(TenantAudit(
|
||||
action='billing_subscription_updated',
|
||||
tenant_id=t.id,
|
||||
details=f'stripe_status={stripe_status} our_status={our_status} prev={old_status}',
|
||||
))
|
||||
|
||||
logger.info('BILLING | subscription_updated | tenant_id=%s %s→%s', t.id, old_status, our_status)
|
||||
|
||||
|
||||
def _on_subscription_deleted(obj):
|
||||
"""customer.subscription.deleted — subscription was cancelled."""
|
||||
customer_id = obj.get('customer')
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant, TenantAudit
|
||||
from control.time_utils import now_eastern
|
||||
from app.billing.emails import send_billing_email
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
logger.warning('BILLING | subscription_deleted_no_tenant | customer=%s', customer_id)
|
||||
return
|
||||
|
||||
t.subscription_status = 'cancelled'
|
||||
billing_email = t.billing_email
|
||||
|
||||
s.add(TenantAudit(
|
||||
action='billing_subscription_cancelled',
|
||||
tenant_id=t.id,
|
||||
details=f'customer={customer_id}',
|
||||
))
|
||||
|
||||
logger.info('BILLING | subscription_deleted | tenant_id=%s', t.id)
|
||||
|
||||
if billing_email:
|
||||
base_url = _get_base_url()
|
||||
send_billing_email(billing_email, 'subscription_cancelled', {
|
||||
'portal_url': f'{base_url}/billing/portal',
|
||||
})
|
||||
|
||||
|
||||
def _on_payment_succeeded(obj):
|
||||
"""invoice.payment_succeeded — payment went through, clear past_due."""
|
||||
customer_id = obj.get('customer')
|
||||
period_end = obj.get('lines', {}).get('data', [{}])[0].get('period', {}).get('end')
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant, TenantAudit
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
return
|
||||
|
||||
if t.subscription_status == 'past_due':
|
||||
t.subscription_status = 'active'
|
||||
if period_end:
|
||||
t.current_period_end = _ts_to_dt(period_end)
|
||||
|
||||
s.add(TenantAudit(
|
||||
action='billing_payment_recovered',
|
||||
tenant_id=t.id,
|
||||
details=f'customer={customer_id}',
|
||||
))
|
||||
logger.info('BILLING | payment_succeeded_recovered | tenant_id=%s', t.id)
|
||||
|
||||
|
||||
def _on_payment_failed(obj):
|
||||
"""invoice.payment_failed — mark past_due and send dunning email."""
|
||||
customer_id = obj.get('customer')
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant, TenantAudit
|
||||
from app.billing.emails import send_billing_email
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
logger.warning('BILLING | payment_failed_no_tenant | customer=%s', customer_id)
|
||||
return
|
||||
|
||||
t.subscription_status = 'past_due'
|
||||
billing_email = t.billing_email
|
||||
|
||||
s.add(TenantAudit(
|
||||
action='billing_payment_failed',
|
||||
tenant_id=t.id,
|
||||
details=f'customer={customer_id}',
|
||||
))
|
||||
|
||||
logger.info('BILLING | payment_failed | tenant_id=%s', t.id)
|
||||
|
||||
if billing_email:
|
||||
base_url = _get_base_url()
|
||||
send_billing_email(billing_email, 'payment_failed', {
|
||||
'portal_url': f'{base_url}/billing/portal',
|
||||
})
|
||||
|
||||
|
||||
def _on_trial_will_end(obj):
|
||||
"""customer.subscription.trial_will_end — sent 3 days before trial ends."""
|
||||
customer_id = obj.get('customer')
|
||||
trial_end_ts = obj.get('trial_end')
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Tenant
|
||||
from app.billing.emails import send_billing_email
|
||||
|
||||
with control_session() as s:
|
||||
t = s.query(Tenant).filter_by(stripe_customer_id=customer_id).first()
|
||||
if t is None:
|
||||
return
|
||||
billing_email = t.billing_email
|
||||
|
||||
if billing_email:
|
||||
trial_dt = _ts_to_dt(trial_end_ts)
|
||||
trial_str = trial_dt.strftime('%B %d, %Y') if trial_dt else 'soon'
|
||||
base_url = _get_base_url()
|
||||
send_billing_email(billing_email, 'trial_ending', {
|
||||
'trial_ends_at': trial_str,
|
||||
'subscribe_url': f'{base_url}/billing/subscribe',
|
||||
})
|
||||
logger.info('BILLING | trial_will_end_email_sent | customer=%s', customer_id)
|
||||
Reference in New Issue
Block a user