# Stripe Subscription Flow — Complete Documentation ## Architecture Overview The Stripe integration runs as **two AWS Lambda functions** triggered via **AWS EventBridge**: 1. **`stripeEvent`** (`lambda/src/stripeEvent.ts`) — handles Stripe webhook events 2. **`billingChange`** (`lambda/src/billingChange.ts`) — handles internal billing timeout/revert events ### Supporting Modules - `eventHandlers/checkout.ts` — checkout session completed logic - `eventHandlers/customer.ts` — subscription updated/deleted logic - `eventHandlers/invoice.ts` — invoice paid/failed logic - `utils/email.ts` — SES email builder and sender - `utils/superAdminLog.ts` — audit log writer - `utils/db.ts` — MongoDB connection --- ## Pricing Model - **Per-office, monthly subscription** using a single Stripe Price ID - `quantity` = number of active offices in the organization - Configured via `STRIPE_PRICE_ID` and `STRIPE_SECRET_KEY` environment variables - The frontend shows: `quantity x price_per_office = total/month` --- ## Stripe Webhook Events Handled | # | Event | Handler | Purpose | |---|-------|---------|---------| | 1 | `checkout.session.completed` | `checkout.sessionCompleted` | New subscription purchased or trial started | | 2 | `invoice.paid` | `invoice.invoicePaid` | Recurring payment succeeded | | 3 | `invoice.payment_failed` | `invoice.invoicePaymentFailed` | Recurring payment failed | | 4 | `customer.subscription.updated` | `customer.subscriptionUpdated` | Subscription status changed or cancel scheduled | | 5 | `customer.subscription.deleted` | `customer.subscriptionDeleted` | Subscription fully canceled/ended | --- ## Complete Lifecycle ### Phase 1: Setup (Super Admin) A super admin switches an organization's payment method to "subscription": - `paymentMethod` set to `"subscription"` - `subscriptionDeadline` set to `now + 24 hours` - Account Manager(s) assigned to the organization - Account Manager receives an email with a link to the billing page ### Phase 2: Checkout (Account Manager) When the Account Manager clicks "Subscribe" on the Billing page: 1. **Validates prerequisites:** - Organization's payment method must be "subscription" - No existing active/trialing subscription - At least one active office exists 2. **Reuses or expires existing checkout session:** - If an open session exists with matching office count → reuses it - If office count changed since last session → expires old session, creates new one 3. **Creates/validates Stripe Customer:** - If `customerId` exists and is not deleted → reuses it - Otherwise creates a new Stripe Customer with org name and AM email 4. **Creates Stripe Checkout Session:** - `mode: "subscription"` - `quantity: activeOfficeCount` - If self-signup org and trial not used → adds `trial_period_days: 30` - Stores `checkoutSessionId` on the organization for session sharing among AMs 5. **Redirects to Stripe-hosted Checkout page** ### Phase 3: Subscription Active (Webhook) When `checkout.session.completed` fires: - Saves `customerId` and `subscriptionId` on the organization - Sets `stripeSubscription.status` to `"active"` or `"trialing"` - Clears `checkoutSessionId` and `subscriptionDeadline` - If trial: marks `trialUsed = true` (prevents re-use) - Creates audit log: `SUBSCRIPTION_PURCHASED` ### Phase 4: Ongoing Operations #### Adding an Office 1. App checks `maxOfficeLimit` — rejects if limit reached 2. Office is created 3. `syncSubscriptionQuantity()` is called: - Counts active offices - Updates Stripe subscription quantity via `stripe.subscriptions.update()` - Stripe creates a **prorated charge** for the remaining days in the billing cycle #### Removing an Office 1. Office is soft-deleted (`isDeleted: true`) 2. `syncSubscriptionQuantity()` is called: - Counts active offices - Updates Stripe subscription quantity - Stripe creates a **prorated credit** for unused days #### Monthly Renewal (invoice.paid) - Sends "Invoice Paid" email to all account managers with: - Invoice number, amount, date, PDF download link - Creates audit log: `INVOICE_PAID` #### Payment Failure (invoice.payment_failed) - Sends "Payment Failed" email to all account managers with: - Invoice details - Warning about deactivation - Link to update payment method (`/login?redirectToBilling=true`) - Creates audit log: `INVOICE_PAYMENT_FAILED` #### Subscription Updated (customer.subscription.updated) - Syncs subscription `status` to the organization record - If `cancel_at_period_end` is true: - Creates audit log: `SUBSCRIPTION_CANCEL_SCHEDULED` ### Phase 5: Cancellation #### User-Initiated Cancel 1. User confirms by typing organization name 2. App calls `stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })` 3. Subscription continues until end of current billing period 4. Creates audit log: `SUBSCRIPTION_CANCEL_SCHEDULED` #### Undo Cancellation 1. User clicks "Undo Cancellation" 2. App calls `stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: false })` 3. Subscription resumes normally 4. Creates audit log: `SUBSCRIPTION_CANCEL_REVERTED` #### Subscription Deleted (at period end) When `customer.subscription.deleted` fires: 1. Clears `subscriptionId`, sets status to `"canceled"` 2. If `paymentMethod !== "invoice"`: - Sets `isActive = false` (deactivates organization) - Sends "Organization Deactivated" email to all account managers 3. Creates audit log: `SUBSCRIPTION_CANCELED` #### Super Admin Deactivation When a super admin suspends an org that has an active subscription: - Calls `stripe.subscriptions.cancel(subscriptionId)` — immediate cancellation - This triggers the `customer.subscription.deleted` webhook --- ## Proration — How It Works ### Stripe's Default Behavior The `syncSubscriptionQuantity()` function does NOT explicitly set `proration_behavior`. This means Stripe applies its default: **`create_prorations`**. ### What Happens When Quantity Changes Mid-Cycle **Office ADDED mid-cycle:** - Stripe calculates remaining days in the current billing period - Creates a prorated line item (charge) on the **next** invoice - Example: $100/office/month, office added on day 15 of 30-day cycle → ~$50 proration charge **Office REMOVED mid-cycle:** - Stripe creates a prorated line item (credit) on the **next** invoice - Example: $100/office/month, office removed on day 10 → ~$67 credit **On the next regular invoice:** - Proration line items (charges and credits) appear alongside the new regular charge at the updated quantity - After this one-time adjustment, billing continues at the standard monthly rate ### Proration Scenarios Table | Scenario | Stripe Behavior | When Charged/Credited | |----------|----------------|----------------------| | Office added mid-cycle | Prorated charge for remaining days | Next invoice | | Office removed mid-cycle | Prorated credit for unused days | Next invoice | | Multiple offices added/removed | Multiple proration line items | Next invoice (net sum) | | Trial → Active (trial ends) | Full charge at current quantity | First real invoice | | Cancel at period end | No proration — runs until period end | Nothing extra | | Quantity unchanged at renewal | Standard charge: qty x price | Regular invoice | ### Important Notes on Prorations 1. **No immediate invoicing** — prorations accumulate to the next scheduled invoice (Stripe is NOT configured for `always_invoice` mode) 2. **No proration preview** — the system does not show users what the proration will be before adding/removing an office 3. **Fire-and-forget sync** — `syncSubscriptionQuantity()` is called with `.catch(() => {})`, meaning office creation/deletion succeeds even if the Stripe sync fails --- ## 24-Hour Deadline Enforcement If an organization is set to `paymentMethod: "subscription"` but doesn't complete Stripe Checkout within 24 hours, a scheduled EventBridge event triggers `billingChange`: ### Reason: `billing_revert` - Reverts `paymentMethod` to `"invoice"` - Clears `subscriptionDeadline` - Removes all account managers (`isAccountManager = false`) - Creates audit log: `BILLING_REVERTED` ### Reason: `activation_timeout` - Deactivates organization (`isActive = false`) - Clears `subscriptionDeadline` - Removes all account managers - Sends "Activation Timeout Deactivated" email to account managers - Creates audit log: `ORG_DEACTIVATED` Both scenarios skip processing if the org already has an active/trialing subscription. --- ## Manage Subscription (Stripe Customer Portal) Account Managers can access the Stripe Billing Portal to: - Update payment method (card) - View invoice history - Download receipts The portal is created via `stripe.billingPortal.sessions.create()` with a return URL to `/billing`. --- ## Maximum Office Limit - `stripeSubscription.maxOfficeLimit` is an **application-level cap only** - Stripe does NOT enforce this limit — it's checked in the office creation controller - Account Managers can update this limit from the Billing page - The limit cannot be set below the current active office count --- ## Emails Sent | Email | Recipients | Trigger | |-------|-----------|---------| | Invoice Paid | All active account managers | `invoice.paid` webhook | | Payment Failed | All active account managers | `invoice.payment_failed` webhook | | Organization Deactivated | All active account managers | `customer.subscription.deleted` (non-invoice orgs) | | Activation Timeout Deactivated | All active account managers | `billingChange` with `activation_timeout` | | Account Manager Added | Newly assigned AM | Super admin assigns AM during setup | | Setup Credit Card | Account Manager | Organization switched to subscription | --- ## Super Admin Audit Log Action Types | Action Type | When | |-------------|------| | `SUBSCRIPTION_PURCHASED` | Checkout completed | | `SUBSCRIPTION` | Checkout session created | | `INVOICE_PAID` | Invoice paid | | `INVOICE_PAYMENT_FAILED` | Invoice payment failed | | `SUBSCRIPTION_CANCEL_SCHEDULED` | Cancel scheduled (user or webhook) | | `SUBSCRIPTION_CANCEL_REVERTED` | Cancel undone by user | | `SUBSCRIPTION_CANCELED` | Subscription deleted | | `OFFICE_QTY_UPDATED` | Max office limit changed | | `ORG_DEACTIVATED` | Activation timeout | | `BILLING_REVERTED` | 24hr deadline expired, reverted to invoice | --- ## API Endpoints | Method | Path | Purpose | |--------|------|---------| | GET | `/payment/api/billing-details` | Get org, office count, and live Stripe subscription | | GET | `/payment/api/price` | Get unit price per office | | PUT | `/payment/api/max-office-limit` | Update maximum office limit | | POST | `/payment/api/create-checkout-session` | Create/reuse Stripe Checkout session | | POST | `/payment/api/cancel-subscription` | Schedule cancellation at period end | | POST | `/payment/api/revert-cancel-subscription` | Undo scheduled cancellation | | POST | `/payment/api/customer-portal` | Create Stripe Billing Portal session | | GET | `/payment/api/payment-history` | List all invoices for the org | --- ## Data Model (Organization.stripeSubscription) ```typescript interface StripeSubscription { customerId?: string | null; // Stripe Customer ID subscriptionId?: string | null; // Stripe Subscription ID status?: "active" | "past_due" | "unpaid" | "canceled" | "incomplete" | "trialing" | null; maxOfficeLimit?: number | null; // App-level cap on offices quantity?: number | null; // Stored quantity (informational) priceId?: string | null; // Stripe Price ID checkoutSessionId?: string | null; // Active checkout session for sharing } ``` Related Organization fields: - `paymentMethod: "invoice" | "subscription"` — billing mode - `subscriptionDeadline: Date | null` — 24hr checkout deadline - `selfSignup: boolean` — whether org was self-created - `trialUsed: boolean` — prevents repeated trials --- ## Flow Diagram ``` Super Admin switches to "subscription" │ ▼ ┌─────────────────────────────────┐ │ 24-hour countdown starts │ │ Account Manager(s) assigned │ │ AM receives email │ └────────────┬────────────────────┘ │ ┌────────┴────────┐ │ │ ▼ ▼ Completes Misses 24hr Checkout deadline │ │ │ ┌──────┴──────┐ │ │ │ │ ▼ ▼ │ billing_revert activation_timeout │ (→ invoice) (→ deactivate) │ ▼ ┌─────────────────────────────────┐ │ SUBSCRIPTION ACTIVE │ │ status: active/trialing │ └────────────┬────────────────────┘ │ ┌─────────┼─────────────┐ │ │ │ ▼ ▼ ▼ Add Office Remove Office Monthly Invoice │ │ │ ▼ ▼ │ qty + 1 qty - 1 ▼ │ │ ┌─────────┐ │ │ │ Paid? │ ▼ ▼ ├─Yes─────┼──→ Email "Invoice Paid" Proration Proration └─No──────┘──→ Email "Payment Failed" charge credit (next inv) (next inv) │ ▼ ┌─────────────────────────────────┐ │ CANCELLATION │ │ │ │ User cancels: │ │ → cancel_at_period_end=true │ │ → runs until period end │ │ → can undo before period end │ │ │ │ Super Admin suspends: │ │ → immediate cancel │ │ │ │ At period end: │ │ → subscription.deleted │ │ → org deactivated │ │ → email sent │ └─────────────────────────────────┘ ``` create pdf for this
mkdir -p /home/daytona/workspace/stripe-docs && ls /home/daytona/workspace/stripe-docs/
~/stripe-docs/generate_pdf.py
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, KeepTogether
)
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.platypus import Flowable
from reportlab.pdfgen import canvas as pdfcanvas
import os
OUTPUT = "/home/daytona/workspace/stripe-docs/Stripe_Subscription_Flow.pdf"
# ── Colours ──────────────────────────────────────────────────────────────────
STRIPE_PURPLE = colors.HexColor("#635BFF")
DARK_BG = colors.HexColor("#1A1A2E")
LIGHT_BG = colors.HexColor("#F7F8FC")
CODE_BG = colors.HexColor("#F0F2F8")
BORDER_COLOR = colors.HexColor("#D0D4E8")
TEXT_DARK = colors.HexColor("#1A1A2E")
TEXT_MED = colors.HexColor("#4A4A6A")
TEXT_LIGHT = colors.HexColor("#7A7A9A")
ACCENT_GREEN = colors.HexColor("#00C48C")
ACCENT_RED = colors.HexColor("#FF5A5F")
ACCENT_AMBER = colors.HexColor("#FFB74D")
WHITE = colors.white
HEADER_BG = colors.HexColor("#2D2D4E")
ROW_ALT = colors.HexColor("#F4F5FB")
# ── Page template with header/footer ─────────────────────────────────────────
class DocTemplate(SimpleDocTemplate):
def __init__(self, filename, **kwargs):
super().__init__(filename, **kwargs)
self.page_count = 0
def handle_pageEnd(self):
self.page_count += 1
super().handle_pageEnd()
def make_header_footer(canvas, doc):
canvas.saveState()
w, h = A4
# Top bar
canvas.setFillColor(STRIPE_PURPLE)
canvas.rect(0, h - 14*mm, w, 14*mm, fill=1, stroke=0)
canvas.setFillColor(WHITE)
canvas.setFont("Helvetica-Bold", 9)
canvas.drawString(18*mm, h - 9*mm, "Stripe Subscription Flow — Complete Documentation")
canvas.setFont("Helvetica", 8)
canvas.drawRightString(w - 18*mm, h - 9*mm, "Internal Engineering Reference")
# Bottom bar
canvas.setFillColor(colors.HexColor("#EAECF6"))
canvas.rect(0, 0, w, 10*mm, fill=1, stroke=0)
canvas.setFillColor(TEXT_MED)
canvas.setFont("Helvetica", 7.5)
canvas.drawString(18*mm, 3.5*mm, "Confidential — Internal Use Only")
canvas.drawRightString(w - 18*mm, 3.5*mm, f"Page {doc.page}")
canvas.restoreState()
# ── Styles ────────────────────────────────────────────────────────────────────
def build_styles():
base = getSampleStyleSheet()
styles = {
"cover_title": ParagraphStyle(
"cover_title", fontSize=30, textColor=WHITE,
fontName="Helvetica-Bold", leading=38, spaceAfter=6
),
"cover_sub": ParagraphStyle(
"cover_sub", fontSize=13, textColor=colors.HexColor("#C8C8FF"),
fontName="Helvetica", leading=18, spaceAfter=4
),
"h1": ParagraphStyle(
"h1", fontSize=16, textColor=STRIPE_PURPLE,
fontName="Helvetica-Bold", leading=20,
spaceBefore=14, spaceAfter=6,
borderPad=0
),
"h2": ParagraphStyle(
"h2", fontSize=12, textColor=HEADER_BG,
fontName="Helvetica-Bold", leading=16,
spaceBefore=10, spaceAfter=4
),
"h3": ParagraphStyle(
"h3", fontSize=10.5, textColor=TEXT_DARK,
fontName="Helvetica-Bold", leading=14,
spaceBefore=8, spaceAfter=3
),
"body": ParagraphStyle(
"body", fontSize=9.5, textColor=TEXT_DARK,
fontName="Helvetica", leading=14,
spaceBefore=2, spaceAfter=4
),
"body_med": ParagraphStyle(
"body_med", fontSize=9.5, textColor=TEXT_MED,
fontName="Helvetica", leading=14,
spaceBefore=2, spaceAfter=4
),
"bullet": ParagraphStyle(
"bullet", fontSize=9.5, textColor=TEXT_DARK,
fontName="Helvetica", leading=14,
spaceBefore=1, spaceAfter=2,
leftIndent=14, bulletIndent=4
),
"sub_bullet": ParagraphStyle(
"sub_bullet", fontSize=9, textColor=TEXT_MED,
fontName="Helvetica", leading=13,
spaceBefore=1, spaceAfter=1,
leftIndent=28, bulletIndent=18
),
"code": ParagraphStyle(
"code", fontSize=8.5, textColor=colors.HexColor("#3730A3"),
fontName="Courier", leading=12,
spaceBefore=1, spaceAfter=1,
leftIndent=8, backColor=CODE_BG,
borderPad=4
),
"code_label": ParagraphStyle(
"code_label", fontSize=8, textColor=TEXT_LIGHT,
fontName="Helvetica-Oblique", leading=11,
spaceBefore=0, spaceAfter=1, leftIndent=8
),
"table_header": ParagraphStyle(
"table_header", fontSize=8.5, textColor=WHITE,
fontName="Helvetica-Bold", leading=11
),
"table_cell": ParagraphStyle(
"table_cell", fontSize=8.5, textColor=TEXT_DARK,
fontName="Helvetica", leading=12
),
"table_cell_code": ParagraphStyle(
"table_cell_code", fontSize=8, textColor=colors.HexColor("#3730A3"),
fontName="Courier", leading=11
),
"caption": ParagraphStyle(
"caption", fontSize=8, textColor=TEXT_LIGHT,
fontName="Helvetica-Oblique", leading=11,
spaceAfter=6, alignment=TA_CENTER
),
"note": ParagraphStyle(
"note", fontSize=8.5, textColor=colors.HexColor("#5B4500"),
fontName="Helvetica", leading=13,
spaceBefore=2, spaceAfter=4,
leftIndent=8, rightIndent=8
),
}
return styles
# ── Helper Flowables ──────────────────────────────────────────────────────────
class SectionDivider(HRFlowable):
def __init__(self):
super().__init__(
width="100%", thickness=1,
color=BORDER_COLOR, spaceAfter=4, spaceBefore=4
)
class PhaseBlock(Flowable):
"""Coloured phase header block."""
def __init__(self, number, title, subtitle, color=STRIPE_PURPLE):
super().__init__()
self.number = number
self.title = title
self.subtitle = subtitle
self.color = color
self.width = 170*mm
self.height = 18*mm
def draw(self):
c = self.canv
c.setFillColor(self.color)
c.roundRect(0, 0, self.width, self.height, 4, fill=1, stroke=0)
# Circle number
c.setFillColor(WHITE)
c.circle(11*mm, self.height/2, 5*mm, fill=1, stroke=0)
c.setFillColor(self.color)
c.setFont("Helvetica-Bold", 9)
c.drawCentredString(11*mm, self.height/2 - 3, str(self.number))
# Title
c.setFillColor(WHITE)
c.setFont("Helvetica-Bold", 11)
c.drawString(20*mm, self.height/2 + 1.5*mm, self.title)
# Subtitle
c.setFont("Helvetica", 8)
c.setFillColor(colors.HexColor("#C8C8FF"))
c.drawString(20*mm, self.height/2 - 4*mm, self.subtitle)
class InfoBox(Flowable):
"""Coloured info callout box."""
def __init__(self, text, bg=colors.HexColor("#EEF0FF"), border=STRIPE_PURPLE,
icon="ℹ", width=170*mm):
super().__init__()
self.text = text
self.bg = bg
self.border = border
self.icon = icon
self._width = width
self.height = 14*mm
def draw(self):
c = self.canv
c.setFillColor(self.bg)
c.roundRect(0, 0, self._width, self.height, 3, fill=1, stroke=0)
c.setStrokeColor(self.border)
c.setLineWidth(1.5)
c.roundRect(0, 0, self._width, self.height, 3, fill=0, stroke=1)
c.setFillColor(self.border)
c.setFont("Helvetica-Bold", 9)
c.drawString(5*mm, self.height/2 - 2*mm, self.icon)
c.setFillColor(colors.HexColor("#2D2060"))
c.setFont("Helvetica", 8.5)
# wrap text manually
lines = self.text.split("\n")
y = self.height/2 + (len(lines)-1)*5 - 2*mm
for line in lines:
c.drawString(12*mm, y, line)
y -= 11
# ── Table helper ──────────────────────────────────────────────────────────────
def make_table(headers, rows, col_widths, s, alt=True):
TC = s["table_cell"]
TH = s["table_header"]
TCC = s["table_cell_code"]
def cell(val, code=False):
return Paragraph(str(val), TCC if code else TC)
data = [[Paragraph(h, TH) for h in headers]]
for row in rows:
data.append([cell(v) for v in row])
ts = [
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1),
[WHITE, ROW_ALT] if alt else [WHITE]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8.5),
]
t = Table(data, colWidths=col_widths)
t.setStyle(TableStyle(ts))
return t
# ── Code block helper ──────────────────────────────────────────────────────────
def code_block(lines, s):
items = []
for line in lines:
items.append(Paragraph(line, s["code"]))
box_data = [[items]]
t = Table(box_data, colWidths=[170*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), CODE_BG),
("BOX", (0, 0), (-1, -1), 0.5, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("ROUNDEDCORNERS", [4, 4, 4, 4]),
]))
return t
# ── Cover page ────────────────────────────────────────────────────────────────
def cover_page(s):
items = []
# Full-page cover is drawn via a special first-page canvas function
items.append(Spacer(1, 52*mm))
items.append(Paragraph("Stripe Subscription Flow", s["cover_title"]))
items.append(Paragraph("Complete Documentation", s["cover_sub"]))
items.append(Spacer(1, 4*mm))
items.append(Paragraph("Internal Engineering Reference • June 2026", s["cover_sub"]))
items.append(Spacer(1, 18*mm))
# Quick-ref badges as a mini table
badge_data = [
[
Paragraph("<b>AWS Lambda</b><br/>2 Functions", s["body"]),
Paragraph("<b>EventBridge</b><br/>Trigger", s["body"]),
Paragraph("<b>Stripe Webhooks</b><br/>5 Events", s["body"]),
Paragraph("<b>MongoDB</b><br/>Data Store", s["body"]),
Paragraph("<b>AWS SES</b><br/>Email", s["body"]),
]
]
bt = Table(badge_data, colWidths=[34*mm]*5)
bt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#2D2060")),
("TEXTCOLOR", (0, 0), (-1, -1), WHITE),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#5050AA")),
]))
items.append(bt)
items.append(PageBreak())
return items
# ── Table of Contents ─────────────────────────────────────────────────────────
def toc_page(s):
items = []
items.append(Paragraph("Table of Contents", s["h1"]))
items.append(SectionDivider())
items.append(Spacer(1, 4*mm))
toc_entries = [
("1.", "Architecture Overview", "3"),
("2.", "Pricing Model", "3"),
("3.", "Stripe Webhook Events", "4"),
("4.", "Complete Lifecycle", "4"),
(" 4.1", "Phase 1: Setup (Super Admin)", "4"),
(" 4.2", "Phase 2: Checkout (Account Manager)", "5"),
(" 4.3", "Phase 3: Subscription Active", "5"),
(" 4.4", "Phase 4: Ongoing Operations", "6"),
(" 4.5", "Phase 5: Cancellation", "7"),
("5.", "Proration — How It Works", "8"),
("6.", "24-Hour Deadline Enforcement", "9"),
("7.", "Manage Subscription (Customer Portal)", "10"),
("8.", "Maximum Office Limit", "10"),
("9.", "Emails Sent", "10"),
("10.", "Super Admin Audit Log", "11"),
("11.", "API Endpoints", "11"),
("12.", "Data Model", "12"),
("13.", "Flow Diagram", "13"),
]
toc_data = []
for num, title, page in toc_entries:
indent = 18*mm if num.startswith(" ") else 0
row = [
Paragraph(f"<font color='#635BFF'>{num}</font>", s["body"]),
Paragraph(title, s["body"]),
Paragraph(page, s["body_med"]),
]
toc_data.append(row)
toc_table = Table(toc_data, colWidths=[16*mm, 140*mm, 14*mm])
toc_table.setStyle(TableStyle([
("ROWBACKGROUNDS", (0, 0), (-1, -1), [WHITE, ROW_ALT]),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("ALIGN", (2, 0), (2, -1), "RIGHT"),
("LINEBELOW", (0, 0), (-1, -1), 0.3, BORDER_COLOR),
]))
items.append(toc_table)
items.append(PageBreak())
return items
# ── Section 1: Architecture Overview ─────────────────────────────────────────
def section_architecture(s):
items = []
items.append(Paragraph("1. Architecture Overview", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
items.append(Paragraph(
"The Stripe integration runs as <b>two AWS Lambda functions</b> triggered via "
"<b>AWS EventBridge</b>:",
s["body"]
))
items.append(Spacer(1, 3*mm))
# Lambda functions table
lambda_data = [
[Paragraph("Function", s["table_header"]), Paragraph("File", s["table_header"]),
Paragraph("Purpose", s["table_header"])],
[Paragraph("<b>stripeEvent</b>", s["table_cell"]),
Paragraph("lambda/src/stripeEvent.ts", s["table_cell_code"]),
Paragraph("Handles Stripe webhook events", s["table_cell"])],
[Paragraph("<b>billingChange</b>", s["table_cell"]),
Paragraph("lambda/src/billingChange.ts", s["table_cell_code"]),
Paragraph("Handles internal billing timeout/revert events", s["table_cell"])],
]
lt = Table(lambda_data, colWidths=[36*mm, 62*mm, 72*mm])
lt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
items.append(lt)
items.append(Spacer(1, 4*mm))
items.append(Paragraph("<b>Supporting Modules</b>", s["h3"]))
modules = [
("eventHandlers/checkout.ts", "Checkout session completed logic"),
("eventHandlers/customer.ts", "Subscription updated/deleted logic"),
("eventHandlers/invoice.ts", "Invoice paid/failed logic"),
("utils/email.ts", "SES email builder and sender"),
("utils/superAdminLog.ts", "Audit log writer"),
("utils/db.ts", "MongoDB connection"),
]
for path, desc in modules:
items.append(Paragraph(
f"<bullet>•</bullet> <font name='Courier' color='#3730A3'>{path}</font> — {desc}",
s["bullet"]
))
items.append(Spacer(1, 6*mm))
return items
# ── Section 2: Pricing Model ──────────────────────────────────────────────────
def section_pricing(s):
items = []
items.append(Paragraph("2. Pricing Model", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
pricing_data = [
[Paragraph("Parameter", s["table_header"]), Paragraph("Value / Description", s["table_header"])],
[Paragraph("Billing model", s["table_cell"]), Paragraph("Per-office, monthly subscription", s["table_cell"])],
[Paragraph("Stripe Price ID", s["table_cell"]), Paragraph("Configured via STRIPE_PRICE_ID env var", s["table_cell"])],
[Paragraph("Quantity", s["table_cell"]), Paragraph("Number of active offices in the organization", s["table_cell"])],
[Paragraph("Secret Key", s["table_cell"]), Paragraph("Configured via STRIPE_SECRET_KEY env var", s["table_cell"])],
[Paragraph("Frontend display", s["table_cell"]), Paragraph("quantity x price_per_office = total/month", s["table_cell"])],
]
pt = Table(pricing_data, colWidths=[50*mm, 120*mm])
pt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
items.append(pt)
items.append(Spacer(1, 6*mm))
return items
# ── Section 3: Webhook Events ─────────────────────────────────────────────────
def section_webhooks(s):
items = []
items.append(Paragraph("3. Stripe Webhook Events Handled", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
headers = ["#", "Event", "Handler", "Purpose"]
rows = [
["1", "checkout.session.completed", "checkout.sessionCompleted", "New subscription purchased or trial started"],
["2", "invoice.paid", "invoice.invoicePaid", "Recurring payment succeeded"],
["3", "invoice.payment_failed", "invoice.invoicePaymentFailed", "Recurring payment failed"],
["4", "customer.subscription.updated", "customer.subscriptionUpdated", "Subscription status changed or cancel scheduled"],
["5", "customer.subscription.deleted", "customer.subscriptionDeleted", "Subscription fully canceled/ended"],
]
data = [[Paragraph(h, s["table_header"]) for h in headers]]
for row in rows:
data.append([
Paragraph(row[0], s["table_cell"]),
Paragraph(row[1], s["table_cell_code"]),
Paragraph(row[2], s["table_cell_code"]),
Paragraph(row[3], s["table_cell"]),
])
t = Table(data, colWidths=[8*mm, 58*mm, 48*mm, 56*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, -1), "CENTER"),
]))
items.append(t)
items.append(Spacer(1, 6*mm))
return items
# ── Section 4: Complete Lifecycle ─────────────────────────────────────────────
def section_lifecycle(s):
items = []
items.append(Paragraph("4. Complete Lifecycle", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
# Phase 1
items.append(PhaseBlock("1", "Phase 1: Setup (Super Admin)", "Initial organization configuration"))
items.append(Spacer(1, 3*mm))
items.append(Paragraph(
"A super admin switches an organization's payment method to <b>\"subscription\"</b>:",
s["body"]
))
for point in [
"<font name='Courier' color='#3730A3'>paymentMethod</font> set to <font name='Courier' color='#3730A3'>\"subscription\"</font>",
"<font name='Courier' color='#3730A3'>subscriptionDeadline</font> set to <b>now + 24 hours</b>",
"Account Manager(s) assigned to the organization",
"Account Manager receives an email with a link to the billing page",
]:
items.append(Paragraph(f"<bullet>•</bullet> {point}", s["bullet"]))
items.append(Spacer(1, 5*mm))
# Phase 2
items.append(PhaseBlock("2", "Phase 2: Checkout (Account Manager)", "Stripe checkout session creation and redirect"))
items.append(Spacer(1, 3*mm))
items.append(Paragraph(
"When the Account Manager clicks <b>\"Subscribe\"</b> on the Billing page:",
s["body"]
))
items.append(Paragraph("<b>Step 1 — Validates prerequisites:</b>", s["h3"]))
for pt in [
"Organization's payment method must be \"subscription\"",
"No existing active/trialing subscription",
"At least one active office exists",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Step 2 — Reuses or expires existing checkout session:</b>", s["h3"]))
for pt in [
"If an open session exists with matching office count → reuses it",
"If office count changed since last session → expires old session, creates new one",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Step 3 — Creates/validates Stripe Customer:</b>", s["h3"]))
for pt in [
"If <font name='Courier' color='#3730A3'>customerId</font> exists and is not deleted → reuses it",
"Otherwise creates a new Stripe Customer with org name and AM email",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Step 4 — Creates Stripe Checkout Session:</b>", s["h3"]))
for pt in [
"<font name='Courier' color='#3730A3'>mode: \"subscription\"</font>",
"<font name='Courier' color='#3730A3'>quantity: activeOfficeCount</font>",
"If self-signup org and trial not used → adds <font name='Courier' color='#3730A3'>trial_period_days: 30</font>",
"Stores <font name='Courier' color='#3730A3'>checkoutSessionId</font> on the organization for session sharing among AMs",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Step 5 — Redirects to Stripe-hosted Checkout page</b>", s["h3"]))
items.append(Spacer(1, 5*mm))
# Phase 3
items.append(PhaseBlock("3", "Phase 3: Subscription Active (Webhook)", "checkout.session.completed fires"))
items.append(Spacer(1, 3*mm))
items.append(Paragraph(
"When <font name='Courier' color='#3730A3'>checkout.session.completed</font> fires:",
s["body"]
))
for pt in [
"Saves <font name='Courier' color='#3730A3'>customerId</font> and <font name='Courier' color='#3730A3'>subscriptionId</font> on the organization",
"Sets <font name='Courier' color='#3730A3'>stripeSubscription.status</font> to <font name='Courier' color='#3730A3'>\"active\"</font> or <font name='Courier' color='#3730A3'>\"trialing\"</font>",
"Clears <font name='Courier' color='#3730A3'>checkoutSessionId</font> and <font name='Courier' color='#3730A3'>subscriptionDeadline</font>",
"If trial: marks <font name='Courier' color='#3730A3'>trialUsed = true</font> (prevents re-use)",
"Creates audit log: <font name='Courier' color='#3730A3'>SUBSCRIPTION_PURCHASED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Spacer(1, 5*mm))
# Phase 4
items.append(PhaseBlock("4", "Phase 4: Ongoing Operations", "Office management, invoicing, and renewals"))
items.append(Spacer(1, 3*mm))
items.append(Paragraph("<b>Adding an Office</b>", s["h3"]))
for pt in [
"App checks <font name='Courier' color='#3730A3'>maxOfficeLimit</font> — rejects if limit reached",
"Office is created",
"<font name='Courier' color='#3730A3'>syncSubscriptionQuantity()</font> is called: counts active offices, updates Stripe subscription quantity via <font name='Courier' color='#3730A3'>stripe.subscriptions.update()</font>",
"Stripe creates a <b>prorated charge</b> for the remaining days in the billing cycle",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Removing an Office</b>", s["h3"]))
for pt in [
"Office is soft-deleted (<font name='Courier' color='#3730A3'>isDeleted: true</font>)",
"<font name='Courier' color='#3730A3'>syncSubscriptionQuantity()</font> is called",
"Stripe creates a <b>prorated credit</b> for unused days",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Monthly Renewal (invoice.paid)</b>", s["h3"]))
for pt in [
"Sends \"Invoice Paid\" email to all account managers with: invoice number, amount, date, PDF download link",
"Creates audit log: <font name='Courier' color='#3730A3'>INVOICE_PAID</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Payment Failure (invoice.payment_failed)</b>", s["h3"]))
for pt in [
"Sends \"Payment Failed\" email to all account managers with invoice details and warning",
"Includes link to update payment method: <font name='Courier' color='#3730A3'>/login?redirectToBilling=true</font>",
"Creates audit log: <font name='Courier' color='#3730A3'>INVOICE_PAYMENT_FAILED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Subscription Updated (customer.subscription.updated)</b>", s["h3"]))
for pt in [
"Syncs subscription <font name='Courier' color='#3730A3'>status</font> to the organization record",
"If <font name='Courier' color='#3730A3'>cancel_at_period_end</font> is true → creates audit log: <font name='Courier' color='#3730A3'>SUBSCRIPTION_CANCEL_SCHEDULED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Spacer(1, 5*mm))
# Phase 5
items.append(PhaseBlock("5", "Phase 5: Cancellation", "User-initiated, admin, or end-of-period"))
items.append(Spacer(1, 3*mm))
items.append(Paragraph("<b>User-Initiated Cancel</b>", s["h3"]))
for pt in [
"User confirms by typing organization name",
"App calls <font name='Courier' color='#3730A3'>stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })</font>",
"Subscription continues until end of current billing period",
"Creates audit log: <font name='Courier' color='#3730A3'>SUBSCRIPTION_CANCEL_SCHEDULED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Undo Cancellation</b>", s["h3"]))
for pt in [
"User clicks \"Undo Cancellation\"",
"App calls <font name='Courier' color='#3730A3'>stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: false })</font>",
"Subscription resumes normally",
"Creates audit log: <font name='Courier' color='#3730A3'>SUBSCRIPTION_CANCEL_REVERTED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Subscription Deleted (at period end — customer.subscription.deleted)</b>", s["h3"]))
for pt in [
"Clears <font name='Courier' color='#3730A3'>subscriptionId</font>, sets status to <font name='Courier' color='#3730A3'>\"canceled\"</font>",
"If <font name='Courier' color='#3730A3'>paymentMethod !== \"invoice\"</font>: sets <font name='Courier' color='#3730A3'>isActive = false</font> (deactivates organization) and sends \"Organization Deactivated\" email",
"Creates audit log: <font name='Courier' color='#3730A3'>SUBSCRIPTION_CANCELED</font>",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph("<b>Super Admin Deactivation</b>", s["h3"]))
for pt in [
"Calls <font name='Courier' color='#3730A3'>stripe.subscriptions.cancel(subscriptionId)</font> — immediate cancellation",
"This triggers the <font name='Courier' color='#3730A3'>customer.subscription.deleted</font> webhook",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(PageBreak())
return items
# ── Section 5: Proration ──────────────────────────────────────────────────────
def section_proration(s):
items = []
items.append(Paragraph("5. Proration — How It Works", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
items.append(Paragraph("<b>Stripe's Default Behavior</b>", s["h2"]))
items.append(Paragraph(
"The <font name='Courier' color='#3730A3'>syncSubscriptionQuantity()</font> function does <b>NOT</b> explicitly set "
"<font name='Courier' color='#3730A3'>proration_behavior</font>. This means Stripe applies its default: "
"<b><font color='#635BFF'>create_prorations</font></b>.",
s["body"]
))
items.append(Spacer(1, 4*mm))
items.append(Paragraph("<b>What Happens When Quantity Changes Mid-Cycle</b>", s["h2"]))
# Side-by-side office added / removed
add_data = [
[Paragraph("Office ADDED mid-cycle", s["table_header"])],
[Paragraph(
"Stripe calculates remaining days in the current billing period. "
"Creates a prorated line item (charge) on the <b>next</b> invoice.\n\n"
"Example: $100/office/month, office added on day 15 of 30 → ~$50 proration charge",
s["table_cell"]
)],
]
rem_data = [
[Paragraph("Office REMOVED mid-cycle", s["table_header"])],
[Paragraph(
"Stripe creates a prorated line item (credit) on the <b>next</b> invoice.\n\n"
"Example: $100/office/month, office removed on day 10 → ~$67 credit",
s["table_cell"]
)],
]
add_t = Table(add_data, colWidths=[84*mm])
add_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), ACCENT_GREEN),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#F0FBF7")),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
rem_t = Table(rem_data, colWidths=[84*mm])
rem_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), ACCENT_AMBER),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF8EE")),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
side_by_side = Table([[add_t, Spacer(2*mm, 1), rem_t]], colWidths=[84*mm, 2*mm, 84*mm])
side_by_side.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
items.append(side_by_side)
items.append(Spacer(1, 5*mm))
items.append(Paragraph("<b>Proration Scenarios Table</b>", s["h2"]))
headers = ["Scenario", "Stripe Behavior", "When Charged/Credited"]
rows = [
["Office added mid-cycle", "Prorated charge for remaining days", "Next invoice"],
["Office removed mid-cycle", "Prorated credit for unused days", "Next invoice"],
["Multiple offices added/removed", "Multiple proration line items", "Next invoice (net sum)"],
["Trial → Active (trial ends)", "Full charge at current quantity", "First real invoice"],
["Cancel at period end", "No proration — runs until period end", "Nothing extra"],
["Quantity unchanged at renewal", "Standard charge: qty x price", "Regular invoice"],
]
items.append(make_table(headers, rows, [62*mm, 68*mm, 40*mm], s))
items.append(Spacer(1, 4*mm))
items.append(Paragraph("<b>Important Notes on Prorations</b>", s["h2"]))
notes = [
("No immediate invoicing", "Prorations accumulate to the next scheduled invoice. Stripe is NOT configured for always_invoice mode."),
("No proration preview", "The system does not show users what the proration will be before adding/removing an office."),
("Fire-and-forget sync", "syncSubscriptionQuantity() is called with .catch(() => {}), meaning office creation/deletion succeeds even if the Stripe sync fails."),
]
for i, (title, body) in enumerate(notes, 1):
note_data = [[
Paragraph(f"<font color='#635BFF'><b>{i}</b></font>", s["body"]),
Paragraph(f"<b>{title}:</b> {body}", s["body"]),
]]
nt = Table(note_data, colWidths=[8*mm, 162*mm])
nt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EEF0FF")),
("BOX", (0, 0), (-1, -1), 1, STRIPE_PURPLE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
items.append(nt)
items.append(Spacer(1, 2*mm))
items.append(PageBreak())
return items
# ── Section 6: 24-Hour Deadline ───────────────────────────────────────────────
def section_deadline(s):
items = []
items.append(Paragraph("6. 24-Hour Deadline Enforcement", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
items.append(Paragraph(
"If an organization is set to <font name='Courier' color='#3730A3'>paymentMethod: \"subscription\"</font> but "
"doesn't complete Stripe Checkout within 24 hours, a scheduled EventBridge event triggers <b>billingChange</b>.",
s["body"]
))
items.append(Spacer(1, 4*mm))
# Two scenarios side by side
r1_data = [
[Paragraph("billing_revert", s["table_header"])],
[Paragraph(
"Reverts paymentMethod to \"invoice\"\nClears subscriptionDeadline\n"
"Removes all account managers (isAccountManager = false)\n"
"Creates audit log: BILLING_REVERTED",
s["table_cell"]
)],
]
r2_data = [
[Paragraph("activation_timeout", s["table_header"])],
[Paragraph(
"Deactivates organization (isActive = false)\nClears subscriptionDeadline\n"
"Removes all account managers\nSends \"Activation Timeout Deactivated\" email\n"
"Creates audit log: ORG_DEACTIVATED",
s["table_cell"]
)],
]
r1t = Table(r1_data, colWidths=[84*mm])
r1t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), ACCENT_AMBER),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF8EE")),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
r2t = Table(r2_data, colWidths=[84*mm])
r2t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), ACCENT_RED),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF0F0")),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
row = Table([[r1t, Spacer(2*mm, 1), r2t]], colWidths=[84*mm, 2*mm, 84*mm])
row.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
items.append(row)
items.append(Spacer(1, 3*mm))
note_data = [[Paragraph(
"<b>Note:</b> Both scenarios skip processing if the org already has an active/trialing subscription.",
s["body"]
)]]
nt = Table(note_data, colWidths=[170*mm])
nt.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EEF0FF")),
("BOX", (0, 0), (-1, -1), 1, STRIPE_PURPLE),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
]))
items.append(nt)
items.append(Spacer(1, 6*mm))
return items
# ── Section 7: Customer Portal ────────────────────────────────────────────────
def section_portal(s):
items = []
items.append(Paragraph("7. Manage Subscription (Stripe Customer Portal)", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
items.append(Paragraph(
"Account Managers can access the Stripe Billing Portal to manage their subscription:",
s["body"]
))
for pt in [
"Update payment method (card)",
"View invoice history",
"Download receipts",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Paragraph(
"The portal is created via <font name='Courier' color='#3730A3'>stripe.billingPortal.sessions.create()</font> "
"with a return URL to <font name='Courier' color='#3730A3'>/billing</font>.",
s["body"]
))
items.append(Spacer(1, 6*mm))
return items
# ── Section 8: Max Office Limit ───────────────────────────────────────────────
def section_max_office(s):
items = []
items.append(Paragraph("8. Maximum Office Limit", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
for pt in [
"<font name='Courier' color='#3730A3'>stripeSubscription.maxOfficeLimit</font> is an <b>application-level cap only</b>",
"Stripe does NOT enforce this limit — it's checked in the office creation controller",
"Account Managers can update this limit from the Billing page",
"The limit cannot be set below the current active office count",
]:
items.append(Paragraph(f"<bullet>•</bullet> {pt}", s["bullet"]))
items.append(Spacer(1, 6*mm))
return items
# ── Section 9: Emails ─────────────────────────────────────────────────────────
def section_emails(s):
items = []
items.append(Paragraph("9. Emails Sent", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
headers = ["Email", "Recipients", "Trigger"]
rows = [
["Invoice Paid", "All active account managers", "invoice.paid webhook"],
["Payment Failed", "All active account managers", "invoice.payment_failed webhook"],
["Organization Deactivated", "All active account managers", "customer.subscription.deleted (non-invoice orgs)"],
["Activation Timeout Deactivated", "All active account managers", "billingChange with activation_timeout"],
["Account Manager Added", "Newly assigned AM", "Super admin assigns AM during setup"],
["Setup Credit Card", "Account Manager", "Organization switched to subscription"],
]
items.append(make_table(headers, rows, [52*mm, 60*mm, 58*mm], s))
items.append(Spacer(1, 6*mm))
return items
# ── Section 10: Audit Log ─────────────────────────────────────────────────────
def section_audit(s):
items = []
items.append(Paragraph("10. Super Admin Audit Log Action Types", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
headers = ["Action Type", "When Triggered"]
rows = [
["SUBSCRIPTION_PURCHASED", "Checkout completed"],
["SUBSCRIPTION", "Checkout session created"],
["INVOICE_PAID", "Invoice paid"],
["INVOICE_PAYMENT_FAILED", "Invoice payment failed"],
["SUBSCRIPTION_CANCEL_SCHEDULED", "Cancel scheduled (user or webhook)"],
["SUBSCRIPTION_CANCEL_REVERTED", "Cancel undone by user"],
["SUBSCRIPTION_CANCELED", "Subscription deleted"],
["OFFICE_QTY_UPDATED", "Max office limit changed"],
["ORG_DEACTIVATED", "Activation timeout"],
["BILLING_REVERTED", "24hr deadline expired, reverted to invoice"],
]
data = [[Paragraph(h, s["table_header"]) for h in headers]]
for row in rows:
data.append([
Paragraph(row[0], s["table_cell_code"]),
Paragraph(row[1], s["table_cell"]),
])
t = Table(data, colWidths=[76*mm, 94*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
items.append(t)
items.append(Spacer(1, 6*mm))
return items
# ── Section 11: API Endpoints ─────────────────────────────────────────────────
def section_api(s):
items = []
items.append(Paragraph("11. API Endpoints", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
headers = ["Method", "Path", "Purpose"]
rows = [
["GET", "/payment/api/billing-details", "Get org, office count, and live Stripe subscription"],
["GET", "/payment/api/price", "Get unit price per office"],
["PUT", "/payment/api/max-office-limit", "Update maximum office limit"],
["POST", "/payment/api/create-checkout-session", "Create/reuse Stripe Checkout session"],
["POST", "/payment/api/cancel-subscription", "Schedule cancellation at period end"],
["POST", "/payment/api/revert-cancel-subscription", "Undo scheduled cancellation"],
["POST", "/payment/api/customer-portal", "Create Stripe Billing Portal session"],
["GET", "/payment/api/payment-history", "List all invoices for the org"],
]
method_colors = {"GET": ACCENT_GREEN, "POST": STRIPE_PURPLE, "PUT": ACCENT_AMBER}
data = [[Paragraph(h, s["table_header"]) for h in headers]]
for row in rows:
method = row[0]
mc = method_colors.get(method, TEXT_MED)
data.append([
Paragraph(f"<font color='{mc.hexval()}'><b>{method}</b></font>", s["table_cell"]),
Paragraph(row[1], s["table_cell_code"]),
Paragraph(row[2], s["table_cell"]),
])
t = Table(data, colWidths=[16*mm, 74*mm, 80*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, -1), "CENTER"),
]))
items.append(t)
items.append(Spacer(1, 6*mm))
return items
# ── Section 12: Data Model ────────────────────────────────────────────────────
def section_data_model(s):
items = []
items.append(Paragraph("12. Data Model", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
items.append(Paragraph("<b>Organization.stripeSubscription</b>", s["h2"]))
ts_lines = [
"interface StripeSubscription {",
" customerId?: string | null; // Stripe Customer ID",
" subscriptionId?: string | null; // Stripe Subscription ID",
' status?: "active" | "past_due" | "unpaid" |',
' "canceled" | "incomplete" | "trialing" | null;',
" maxOfficeLimit?: number | null; // App-level cap on offices",
" quantity?: number | null; // Stored quantity (informational)",
" priceId?: string | null; // Stripe Price ID",
" checkoutSessionId?: string | null; // Active checkout session",
"}",
]
items.append(code_block(ts_lines, s))
items.append(Spacer(1, 4*mm))
items.append(Paragraph("<b>Related Organization Fields</b>", s["h2"]))
headers = ["Field", "Type", "Description"]
rows = [
["paymentMethod", '"invoice" | "subscription"', "Billing mode"],
["subscriptionDeadline", "Date | null", "24hr checkout deadline"],
["selfSignup", "boolean", "Whether org was self-created"],
["trialUsed", "boolean", "Prevents repeated trials"],
]
data = [[Paragraph(h, s["table_header"]) for h in headers]]
for row in rows:
data.append([
Paragraph(row[0], s["table_cell_code"]),
Paragraph(row[1], s["table_cell_code"]),
Paragraph(row[2], s["table_cell"]),
])
t = Table(data, colWidths=[50*mm, 60*mm, 60*mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, ROW_ALT]),
("GRID", (0, 0), (-1, -1), 0.4, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
items.append(t)
items.append(PageBreak())
return items
# ── Section 13: Flow Diagram (text-based) ────────────────────────────────────
def section_flow(s):
items = []
items.append(Paragraph("13. Flow Diagram", s["h1"]))
items.append(HRFlowable(width="100%", thickness=2, color=STRIPE_PURPLE, spaceAfter=6))
# Draw a visual flow diagram using Tables as blocks
def flow_box(text, bg, text_color=WHITE, width=150*mm, border=None):
data = [[Paragraph(text, ParagraphStyle(
"fb", fontSize=9, textColor=text_color,
fontName="Helvetica-Bold", leading=13, alignment=TA_CENTER
))]]
ts_list = [
("BACKGROUND", (0, 0), (-1, -1), bg),
("TOPPADDING", (0, 0), (-1, -1), 7),
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
]
if border:
ts_list.append(("BOX", (0, 0), (-1, -1), 1.5, border))
t = Table(data, colWidths=[width])
t.setStyle(TableStyle(ts_list))
return t
def arrow(label="", width=150*mm):
data = [[Paragraph(
f"<font color='#635BFF'>▼</font> {label}" if label else "<font color='#635BFF'>▼</font>",
ParagraphStyle("arr", fontSize=9, textColor=TEXT_MED,
fontName="Helvetica", leading=12, alignment=TA_CENTER)
)]]
t = Table(data, colWidths=[width])
t.setStyle(TableStyle([
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
]))
return t
# Outer container
flow_items = []
flow_items.append(flow_box("Super Admin switches org to \"subscription\"", DARK_BG, WHITE))
flow_items.append(Spacer(1, 1*mm))
flow_items.append(arrow())
flow_items.append(Spacer(1, 1*mm))
flow_items.append(flow_box("24-hour countdown starts\nAccount Manager(s) assigned + receives email", HEADER_BG, WHITE))
flow_items.append(Spacer(1, 2*mm))
# Branch
branch_data = [[
flow_box("Completes Checkout", ACCENT_GREEN, WHITE, width=72*mm),
Spacer(6*mm, 1),
flow_box("Misses 24hr deadline", ACCENT_RED, WHITE, width=72*mm),
]]
bt = Table(branch_data, colWidths=[72*mm, 6*mm, 72*mm])
bt.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
flow_items.append(bt)
flow_items.append(Spacer(1, 2*mm))
sub_branch = Table([[
flow_box("billing_revert\n(→ invoice)", ACCENT_AMBER, colors.HexColor("#5B4500"), width=72*mm),
Spacer(6*mm, 1),
flow_box("activation_timeout\n(→ deactivate)", ACCENT_RED, WHITE, width=72*mm),
]], colWidths=[72*mm, 6*mm, 72*mm])
sub_branch.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
flow_items.append(sub_branch)
flow_items.append(Spacer(1, 4*mm))
flow_items.append(arrow("checkout.session.completed"))
flow_items.append(Spacer(1, 1*mm))
flow_items.append(flow_box("SUBSCRIPTION ACTIVE\nstatus: active / trialing", STRIPE_PURPLE, WHITE))
flow_items.append(Spacer(1, 2*mm))
# Three branches: Add Office, Remove Office, Monthly Invoice
ops_data = [[
flow_box("Add Office\nqty + 1\nProration charge\n(next invoice)", ACCENT_GREEN, WHITE, width=52*mm),
Spacer(3*mm, 1),
flow_box("Remove Office\nqty - 1\nProration credit\n(next invoice)", ACCENT_AMBER, colors.HexColor("#5B4500"), width=52*mm),
Spacer(3*mm, 1),
flow_box("Monthly Invoice\nPaid → Email AM\nFailed → Email AM", HEADER_BG, WHITE, width=52*mm),
]]
ot = Table(ops_data, colWidths=[52*mm, 3*mm, 52*mm, 3*mm, 52*mm])
ot.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
flow_items.append(ot)
flow_items.append(Spacer(1, 3*mm))
flow_items.append(arrow())
flow_items.append(Spacer(1, 1*mm))
cancel_box_data = [[Paragraph(
"<b>CANCELLATION</b>\n\n"
"User cancels: cancel_at_period_end = true → runs until period end → can undo\n"
"Super Admin suspends: immediate cancel\n"
"At period end: subscription.deleted → org deactivated → email sent",
ParagraphStyle("cb", fontSize=9, textColor=WHITE,
fontName="Helvetica", leading=13, alignment=TA_LEFT)
)]]
ct = Table(cancel_box_data, colWidths=[150*mm])
ct.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), ACCENT_RED),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("BOX", (0, 0), (-1, -1), 1.5, colors.HexColor("#CC0000")),
]))
flow_items.append(ct)
# Wrap everything in a centred table
outer = Table([[fi] for fi in flow_items], colWidths=[150*mm])
outer.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
items.append(outer)
items.append(Spacer(1, 6*mm))
return items
# ── Cover canvas ──────────────────────────────────────────────────────────────
def cover_canvas(canvas, doc):
"""Special first page: full dark background, no header/footer."""
w, h = A4
canvas.saveState()
# Full background gradient simulation (two rects)
canvas.setFillColor(DARK_BG)
canvas.rect(0, 0, w, h, fill=1, stroke=0)
# Accent stripe
canvas.setFillColor(STRIPE_PURPLE)
canvas.rect(0, h * 0.42, w, 3, fill=1, stroke=0)
# Decorative circles
canvas.setFillColor(colors.HexColor("#2D2060"))
canvas.circle(w - 40*mm, h - 40*mm, 50*mm, fill=1, stroke=0)
canvas.setFillColor(colors.HexColor("#1E1850"))
canvas.circle(20*mm, 30*mm, 35*mm, fill=1, stroke=0)
# Stripe wordmark-style bar
canvas.setFillColor(STRIPE_PURPLE)
canvas.roundRect(18*mm, h * 0.42 + 8*mm, 45*mm, 10*mm, 2, fill=1, stroke=0)
canvas.setFillColor(WHITE)
canvas.setFont("Helvetica-Bold", 9)
canvas.drawString(20*mm, h * 0.42 + 11*mm, "STRIPE INTEGRATION")
canvas.restoreState()
# ── Main build ────────────────────────────────────────────────────────────────
def build():
s = build_styles()
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
leftMargin=20*mm, rightMargin=20*mm,
topMargin=22*mm, bottomMargin=16*mm,
title="Stripe Subscription Flow — Complete Documentation",
author="Engineering Team",
subject="Internal Stripe Integration Reference",
)
story = []
# Cover
story.extend(cover_page(s))
# TOC
story.extend(toc_page(s))
# Sections
story.extend(section_architecture(s))
story.extend(section_pricing(s))
story.extend(section_webhooks(s))
story.extend(section_lifecycle(s))
story.extend(section_proration(s))
story.extend(section_deadline(s))
story.extend(section_portal(s))
story.extend(section_max_office(s))
story.extend(section_emails(s))
story.extend(section_audit(s))
story.extend(section_api(s))
story.extend(section_data_model(s))
story.extend(section_flow(s))
def first_page(canvas, doc):
cover_canvas(canvas, doc)
def later_pages(canvas, doc):
make_header_footer(canvas, doc)
doc.build(story, onFirstPage=first_page, onLaterPages=later_pages)
print(f"PDF written to {OUTPUT}")
if __name__ == "__main__":
build()
cd /home/daytona/workspace/stripe-docs && python generate_pdf.py 2>&1
ls -lh /home/daytona/workspace/stripe-docs/Stripe_Subscription_Flow.pdf
Stripe Subscription Flow
PDF Document · PDF