# 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

Reading File
Reading File
Running Command

mkdir -p /home/daytona/workspace/stripe-docs && ls /home/daytona/workspace/stripe-docs/

Writing File

~/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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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>&bull;</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'>&#9660;</font> {label}" if label else "<font color='#635BFF'>&#9660;</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()
Running Command

cd /home/daytona/workspace/stripe-docs && python generate_pdf.py 2>&1

Running Command

ls -lh /home/daytona/workspace/stripe-docs/Stripe_Subscription_Flow.pdf

Your PDF is ready:

Stripe Subscription Flow

PDF Document · PDF

Here's what's included across ~13 sections:
  • Cover page - dark branded cover with architecture badge row (Lambda, EventBridge, Stripe, MongoDB, SES)
  • Table of Contents - all 13 sections with page numbers
  • Architecture Overview - Lambda functions table + supporting modules
  • Pricing Model - env vars, quantity model
  • Webhook Events - all 5 events in a formatted table
  • Complete Lifecycle - all 5 phases rendered as colored phase blocks with step-by-step bullets
  • Proration - side-by-side add/remove boxes, scenarios table, and 3 important notes callouts
  • 24-Hour Deadline - two-column billing_revert vs activation_timeout breakdown
  • Customer Portal, Max Office Limit, Emails, Audit Log, API Endpoints - all as styled tables
  • Data Model - TypeScript interface in a code block + related fields table
  • Flow Diagram - visual block diagram with colored boxes showing the full lifecycle flow
This is a shared conversation. Sign in to Orris to start your own chat.