shipped

Spec: F74 — Subscription Management & Invoicing (per organization)

Status: shipped Builds on: F24 Billing (payment rails/webhooks) · F53 Pricing (public plan catalog) · F72 Freemium (entitlements/upgrade prompts) Security-critical: every PR here gets a security review pass before merge (payment + tenant data).

1. Problem & CEO Job

Lumi can take a payment (F24) but a CEO cannot yet manage what they bought. There is no self-service way to see the current plan and renewal date in one place, upgrade or downgrade mid-cycle, cancel without emailing support, or download an invoice for the accountant. For an SME owner in Hanoi or Kuala Lumpur, “I can’t get a hóa đơn / invoice” and “I can’t cancel myself” are trust-breakers — they make Lumi feel like a trap, not a tool. This serves trust the signal: billing must be as transparent and reversible as every other commitment in the product, scoped per organization so a multi-company owner manages each company’s plan independently.

The contract: an owner/admin opens Settings → Subscription and, in under a minute and without contacting anyone, can see exactly what they pay, change it, stop it, and get a document for it — while a member sees the truth without the management controls.

2. User Stories & Acceptance Criteria

  • As an owner/admin, I want to see my current plan, status, price, billing period, and next renewal/expiry date in one place, so that I always know what I’m paying and when.

    • Given an active subscription, when I open Settings → Subscription, then plan name, price, billing period, status, current-period start/end, and cancel_at_period_end are visible.
    • Given a free/none org, then the screen shows the Free plan with an upgrade CTA (reuses F72 entitlements), not an error.
  • As an owner/admin, I want to upgrade or downgrade my plan mid-cycle, so that I pay for what I actually need.

    • Given an active paid plan, when I choose a higher plan, then a proration credit for unused time on the current plan is applied to the new plan’s charge, and a PaymentIntent for the net amount is created via the F24 rails.
    • Given an active paid plan, when I choose a lower plan, then the downgrade is scheduled for period end (no refund mid-cycle in v1); the current plan’s entitlements hold until then.
    • Given a change to the same plan+period, then the request is a no-op 200 (idempotent), no charge.
    • Given an unexpired pending payment for the same gateway, then an exact retry reuses it; a request with a different plan, period, or seat count atomically cancels the stale intent and creates one replacement, so only the latest checkout can activate.
  • As an owner/admin, I want to cancel my subscription myself, so that I’m never locked in.

    • Given an active subscription, when I cancel, then by default it is set cancel_at_period_end=true — access continues until period end, then transitions to the F24 grace→read-only lifecycle. Data is never deleted.
    • Given a scheduled cancellation, when I reactivate before period end, then cancel_at_period_end=false and billing resumes normally.
    • Given a cancellation, an audit log row (F16) and a confirmation email (F35) are written.
  • As an owner/admin, I want an invoice for every successful payment, so that my accountant has a document.

    • Given a successful PaymentIntent (any gateway, incl. store), then an Invoice is generated with a sequential per-org number, line items, amount, currency, tax line (0 in v1), and issued timestamp.
    • Given the billing history, when I open an invoice, then I can view it and download a PDF/HTML receipt; the download is scoped to my org and rejects other orgs’ invoice IDs.
  • As a member (non-billing role), I want to see the plan but not manage it, so that I understand entitlements without being shown controls I can’t use.

    • Given a member, then the subscription screen is read-only: no change/cancel/invoice-download controls; shows “Contact {owner} to manage billing” (reuses F72 contact_owner_name).
  • As a multi-org owner, I want management scoped to the current org, so that changing one company never touches another.

    • Given org A and org B, when I cancel/upgrade/download in A, then B’s subscription and invoices are untouched; every endpoint scopes by X-Organization-ID.

3. Scope Boundaries

  • In: Settings → Subscription management surface; current-plan view; upgrade (with proration credit) / downgrade (scheduled at period end); self-service cancel + reactivate (cancel_at_period_end); Invoice model + generation on payment success; invoice list/detail/download (PDF or HTML receipt); per-org isolation; owner/admin-only mutations with member read-only; audit + email hooks.
  • Out: payment rail mechanics, webhooks, store-verify (all F24 — F74 calls them); public pricing/marketing (F53); freemium feature gating (F72); legally-compliant Vietnamese e-invoice (hóa đơn điện tử) integration with a tax authority provider (deferred — v1 issues an internal receipt, not a GDT-registered hóa đơn); VAT/tax computation engine (tax line is 0/placeholder in v1); dunning sequences beyond F24’s grace→read-only; saved card / payment-method vaulting (no card data touches Lumi — F24); mid-cycle cash refunds (handled manually via gateway dashboard, per F24 §11).

4. Data Model

App billing (existing). Changes:

  • Subscription (existing) — add cancel_at_period_end: BooleanField(default=False). No new status value; the F24 lifecycle (trialing/active/grace/read_only/cancelled) is unchanged. When cancel_at_period_end and the period expires, the existing transition_expired() path runs (grace→read_only), and activate_subscription is not re-invoked.
  • PlanChange (new, UUIDModel, organization FK) — records a scheduled downgrade or applied upgrade: from_plan, to_plan, billing_period, effective (immediate|period_end), proration_credit (int, VND, 0 for downgrades), status (scheduled|applied|cancelled), effective_at. Lets a scheduled downgrade apply at period end deterministically.
  • Invoice (new, UUIDModel, organization FK, payment_intent FK nullable, subscription FK nullable) — number (per-org sequential, e.g. INV-<org_seq>), issued_at, period_start, period_end, subtotal, tax (0 v1), total, currency, status (issued|paid|void), line_items_json (list of {description, qty, unit_amount, amount}), pdf_path (S3 key, nullable — generated lazily). Generated in the shared activate_subscription path so every gateway (Sepay/Stripe/PayPal/Airwallex/store) yields an invoice.

Per-org sequential numbering uses a select_for_update counter row to avoid gaps/races (see backend/DIAGRAMS.md). Update the subscription state machine diagram with the cancel_at_period_end flag and GLOSSARY with Invoice, PlanChange, proration credit.

5. API Contract

All under /api/billing/, scoped to request.organization_id. Mutations: owner/admin only (reuse _can_manage_billing).

MethodPathRoleRequestResponseErrors
GETsubscription/member—F24 state + cancel_at_period_end, scheduled_change—
POSTchange-plan/owner/admin{plan_code, billing_period}upgrade → {payment_code, checkout_url?, proration_credit, amount}; downgrade → {scheduled: true, effective_at}400 invalid plan/period · 403 not permitted · 409 same plan
POSTcancel/owner/admin{immediate?: bool=false}{cancel_at_period_end, access_until}403 · 409 no active sub
POSTreactivate/owner/admin—{cancel_at_period_end:false}403 · 409 not scheduled to cancel
GETinvoices/owner/admin—[{id, number, issued_at, total, currency, status}] (keyset paginated)403
GETinvoices/{id}/owner/admin—full invoice incl. line_items403 · 404 (cross-org ID → 404, never 403-leak)
GETinvoices/{id}/download/owner/admin—PDF/HTML receipt stream (or pre-signed S3 URL)403 · 404

Tenant scoping: every queryset filters organization_id=request.organization_id; cross-org invoice/sub IDs return 404 (existence not leaked). Upgrade/downgrade and cancel reuse F24’s create_payment_intent / activate_subscription / cancel helpers — F74 adds no new gateway code. No EvidenceSource (not AI output).

Contract Graph ownership: the shipped invoice detail and HTML receipt download operations are mapped to billing_invoice_detail and billing_invoice_download in the billing URL/view layer. This records source ownership only; it does not promote browser, native, PDF/legal, live-provider, or production evidence.

6. UI / UX

  • Route: /settings → Subscription card/section (owner/admin) — extends the existing F24 BillingCard, not a new page.
  • Components: shadcn Card, Button, Badge, Dialog (confirm cancel/downgrade), Table (invoice history via F66 DataTable), AlertDialog for destructive cancel. Check frontend/src/components/ui/ first; reuse GraceBanner, UpgradeGate.
  • States to design: free (upgrade pitch), active (plan + manage controls + invoice table), scheduled-cancel (banner “ends on {date}” + Reactivate), scheduled-downgrade (banner “changes to {plan} on {date}”), loading, error, member read-only (no controls + contact-owner copy), empty invoices.
  • Flows: Upgrade → confirm proration → F24 checkout/QR → success polls subscription. Downgrade → confirm “applies at period end” → scheduled banner. Cancel → AlertDialog (immediate vs period-end) → scheduled banner. Invoice row → view → Download.
  • i18n: extend billing namespace — manage_title, change_plan, upgrade_now, downgrade_scheduled, cancel_subscription, cancel_confirm_title, cancel_confirm_body, cancel_at_period_end, reactivate, invoices_title, invoice_number, invoice_download, proration_credit, access_until, manage_contact_owner — en + vi together (plus expansion locales per F69 propagation).
  • DESIGN.md billing/settings surface tokens; TASTE.md pre-flight applies (one primary action per state; destructive cancel uses a confirm, reversible reactivate uses undo affordance).

7. Test Plan

#CaseTypeWhere
1cancel/ sets cancel_at_period_end, access until period end, data preservedbackendbackend/tests/test_billing_management.py::TestCancel
2reactivate/ clears scheduled cancelbackendsame
3Upgrade computes proration credit, creates net PaymentIntentbackendTestChangePlan::test_upgrade_proration
4Downgrade scheduled at period end; entitlements unchanged until thenbackendTestChangePlan::test_downgrade_scheduled
5Same-plan change → 200 no-opbackendTestChangePlan::test_noop
6Invoice generated on every gateway’s activate_subscriptionbackendTestInvoice::test_generated_on_payment
7Per-org sequential invoice numbers, no gaps under concurrencybackendTestInvoice::test_sequential_numbers
8Cross-tenant: org B cannot read/download org A’s invoice (404), cancel A doesn’t touch Bbackendbackend/tests/test_multitenancy.py
9Member cannot cancel/change/download (403); can read subscriptionbackendTestBillingRoles
10Invoice download returns PDF/HTML; cross-org id → 404backendTestInvoice::test_download
11Settings → Subscription: manage controls (owner), read-only (member), invoice table renderse2efrontend/tests/e2e/lumi.spec.ts describe("Subscription Management")
12A changed plan request through the public pay endpoint replaces an incompatible pending intent without violating the one-pending-per-org/gateway constraintbackendTestPayEndpoint::test_changed_plan_replaces_incompatible_pending_intent

Backend coverage must stay ≥97%; new E2E describe block required.

8. Current Behavior

F74 is shipped on web: the billing API exposes subscription truth, pay, change-plan, cancel, reactivate, invoice list/detail/download, and store verification. The Settings surface supports owner/admin management and member read-only truth. Native parity, live-provider proof, PDF/legal invoicing, and commit-bound PostgreSQL concurrency evidence remain explicit gaps.

9. Error States

ErrorUser message (i18n)HTTPLogged
Member attempts manage actionbilling.error_not_permitted403No
Cancel with no active subscriptionbilling.error_no_active_sub409No
Reactivate when not scheduled to cancelbilling.error_not_scheduled409No
Change to same plan+period(none — success no-op)200No
Invalid plan/periodbilling.error_invalid_plan / billing.error_invalid_period400No
Cross-org invoice/sub idgeneric not-found (no existence leak)404Yes (server)
Invoice PDF generation failurebilling.error_invoice_unavailable; fall back to HTML receipt200/502Yes
Gateway failure on upgrade chargereuse F24 gateway error states (error_gateway_*)502/503Yes
Changed request while a gateway payment is pendingprior intent becomes cancelled; return a fresh checkout for the latest plan/period/seats200No

Internal: management mutations wrapped in a DB transaction; invoice numbering uses select_for_update. Cancel/upgrade/downgrade are idempotent on retry.

10. Diagrams & Docs Touched

backend/DIAGRAMS.md (subscription state machine + cancel_at_period_end; new Invoice/PlanChange in ER diagram) · frontend/DIAGRAMS.md (settings subscription states) · GLOSSARY (Invoice, PlanChange, proration credit, cancel at period end) · F24 §3 (move invoices/proration from “Out” → “delegated to F74”) · possible ADR for proration policy + invoice numbering scheme.

11. Implementation Audit (2026-06-17)

Source-audited and registered for verification. The implementation and test targets below exist, but this Contract Graph slice does not claim browser, native, live-provider, or production execution without commit-bound runtime evidence. Collection is not execution evidence.

#ClaimStatusEvidence
1Subscription.cancel_at_period_end + Invoice + PlanChange (+ InvoiceCounter per-org sequence)✅backend/apps/billing/models.py; migration 0006_subscription_cancel_at_period_end_invoicecounter_and_more
2cancel/, reactivate/, change-plan/, invoices/, invoices/{id}/, invoices/{id}/download/✅backend/apps/billing/views.py + urls.py
3change_plan upgrade=proration credit+net PaymentIntent, downgrade=scheduled, same-plan no-op✅services/__init__.py::change_plan, _proration_credit — prices via bands.price_for (ADR-024 §3); see 2026-07-02 bug fix
4Invoice generated in shared activate_subscription; race-safe select_for_update numbering✅services/__init__.py::generate_invoice, _next_invoice_number
5Cancel writes F16 audit + F35 email; reactivate writes audit✅services.py
6Tenant isolation: cross-org invoice → 404; per-org numbering; member 403✅tests/test_billing_management.py, tests/test_multitenancy.py::TestBillingManagementTenantIsolation
7Web: SubscriptionManagement (change/cancel/reactivate + invoice table/download), hooks, en+vi i18n✅frontend/src/components/billing/SubscriptionManagement.tsx, hooks/queries/useBilling.ts, api/endpoints.ts
8Settings renders subscription management for members as read-only, while keeping billing card owner/admin-only✅frontend/src/pages/settings/SettingsPage.tsx, frontend/src/test/SettingsPage.test.tsx
9E2E target covers owner invoice/cancel/reactivate flow and member read-only visibility in Settings🚧Registered in frontend/tests/e2e/lumi.part03.spec.ts describe("Subscription Management"); runtime execution is unverified

Remaining gaps (ordered):

  1. Runtime proof — backend and Vitest targets are registered, but execution depends on local runner environments; the Playwright journey remains unverified.
  2. PDF invoices — v1 download returns an HTML receipt; PDF deferred per §12 (no heavy dep yet).
  3. Native (iOS/Android/Mac) — management UI not ported to mobile shells → platform cells 🔲.
  4. Legal hóa đơn điện tử — internal receipt only; GDT-registered e-invoice deferred per §12.

12. Open Questions / Decisions

  • Proration policy: v1 = credit unused time on upgrade, no cash refund on downgrade (schedule at period end). Confirm before build; if more complex, needs an ADR.
  • Invoice vs. legal hóa đơn: v1 issues an internal receipt (PDF/HTML). GDT-registered Vietnamese e-invoice integration is deferred — decide provider (e.g. VNPT/Viettel e-invoice) when volume justifies.
  • Invoice numbering scheme: per-org sequential (INV-{org}-{n}) vs global — leaning per-org for tenant cleanliness; confirm with finance.
  • PDF generation: server-side (e.g. WeasyPrint/ReportLab) vs HTML receipt only in v1 — start with HTML receipt + deferred PDF to avoid a heavy dependency.
  • Tax line: placeholder 0 in v1; VAT computation is out of scope until a tax engine decision.

13. Bug / Change Log

2026-07-25 — Public pay endpoint now honors pending-intent identity

Type: Bug fix

Summary:

  • The July 20 service fix correctly reused only exact plan/period/seat retries and replaced incompatible pending intents, but POST /api/billing/pay/ still returned the newest pending row by gateway before calling that service.
  • A user who started one checkout and then chose another plan could therefore receive the old payment code and QR details instead of a checkout for the new plan.
  • The endpoint now delegates the reuse-or-replace decision to resolve_payment_intent(), which returns both the authoritative intent and whether it was created. Exact retries still return the existing checkout; changed requests cancel the stale intent and return one replacement.

Evidence:

  • Feedback: streamentry/lumi-fb#39.
  • Sentry: unresolved LUMI-BE-1T recorded the same one-pending-intent failure family.

Tests:

  • TestPayEndpoint.test_changed_plan_replaces_incompatible_pending_intent reproduces the public endpoint path and proves the stale intent is cancelled, the selected plan owns the replacement, and only one pending gateway row survives.
  • Existing exact-retry coverage continues to prove the same request reuses its payment code.

2026-07-02 — Bug fix: change_plan/_proration_credit use PlanPrice not deprecated monthly_price

Type: Bug fix

Summary:

  • change_plan() and _proration_credit() in services/__init__.py were reading Plan.price_for_period() / Plan.monthly_price (DEPRECATED since ADR-024 §3 / P29). In production all plans have monthly_price=0 (see seed_plans.py), so every plan-change comparison evaluated 0 >= 0 → is_upgrade=True, misclassifying every downgrade as an immediate upgrade and returning proration_credit=0 / amount=0 for all plan changes.
  • Fixed both functions to use bands.price_for(org, plan, period) (same authoritative source as create_payment_intent), with the identical try/except graceful fallback for orgs without PlanPrice rows.

Impact:

  • Correct upgrade vs downgrade classification for all production plan changes.
  • Correct non-zero proration_credit and amount in upgrade response dicts.
  • create_payment_intent was not changed — the actual charge was always correct.
  • Postmortem: docs/postmortems/2026-07-02-change-plan-deprecated-pricing.md.

Tests:

  • Added TestChangePlanWithPlanPrice class in tests/test_billing_management.py. Fixtures use monthly_price=0 + PlanPrice rows (production-realistic). Key case: test_downgrade_pro_to_starter_is_scheduled_not_upgrade — the broken scenario.

2026-07-02 — Spec drift fix: checked shipped ACs in §2

Type: Documentation

Summary:

  • 12 of 12 [ ] ACs in §2 flipped to [x] — confirmed shipped against §11 audit rows 1–9 (all ✅).
  • No code changes.

2026-06-15 — Spec created

Type: Feature

Summary:

  • New spec for per-organization subscription management + invoicing: view plan, upgrade (proration credit), downgrade (period-end), self-service cancel + reactivate (cancel_at_period_end), and Invoice generation/list/download.

Reason:

  • F24 ships payment rails but no self-service lifecycle or invoices (explicitly “Out” in F24 §3). SME owners need to manage and document their subscription without contacting support.

Impact:

  • New billing endpoints + Invoice/PlanChange models + Subscription.cancel_at_period_end (append-only migration). Extends F24’s BillingCard UI. F24 §3 updated to delegate invoices/proration here.

Tests:

  • To be added with implementation (see §7): test_billing_management.py, test_multitenancy.py rows, E2E describe("Subscription Management").

Notes:

  • Reuses F24 gateways and activate_subscription; adds no new payment-rail code. Do not duplicate F24/F53/F72.

2026-07-16 — Contract Graph evidence audit

Type: Evidence correction

Summary:

  • Reconciled the shipped billing-management source and tests into contracts/features/F74.cue.
  • Downgraded the browser row from end-to-end verified to registered/runtime unverified because no current browser execution artifact is attached.
  • Preserved explicit gaps for missing runner environments, native parity, HTML-versus-PDF/legal invoice scope, and live-provider/production evidence.
  • Assigned the shipped invoice detail and HTML receipt download URL surfaces to their exact billing view symbols.

2026-06-17 — Settings visibility + E2E verification

Type: Fix

Summary:

  • Exposed the subscription-management surface to non-billing members as read-only inside Settings, and added Playwright coverage for owner and member subscription states.

Reason:

  • The component already supported member read-only behavior, but SettingsPage hid it behind the billing-management gate, which contradicted the F74 acceptance criteria.

Impact:

  • Members can now see the org subscription state and owner-contact guidance without mutation controls.
  • F74 web E2E is now verified on the actual /settings surface.

Tests:

  • pnpm --dir frontend exec vitest run src/test/SettingsPage.test.tsx src/test/subscription-management.test.tsx
  • pnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Subscription Management" --project=chromium
  • pnpm --dir frontend exec tsc --noEmit

2026-07-20 — Replace incompatible pending payment intents atomically

Type: Bug Fix

Summary:

  • create_payment_intent() reused only an exact plan and billing-period match, while PostgreSQL permits only one pending intent per organization, gateway, and pending status.
  • A changed upgrade request therefore attempted a second pending insert and returned an unhandled IntegrityError instead of a checkout.
  • Exact retries continue to reuse the existing intent. A different plan, period, or seat count now cancels the prior pending intent and creates the latest requested checkout under an organization row lock.

Verification:

  • TestChangePlan.test_changed_upgrade_replaces_pending_intent reproduces Sentry LUMI-BE-1T and proves the old intent is cancelled, the replacement is pending, and only one pending row remains.