Jun 28 - Implement payment functions (Stripe)

This commit is contained in:
2026-06-28 12:01:25 -04:00
parent 61ede27093
commit f292c8fb7b
21 changed files with 1112 additions and 18 deletions
+15
View File
@@ -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
+105
View File
@@ -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()
+207
View File
@@ -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'))
+87
View File
@@ -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)
+263
View File
@@ -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)