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_endare visible. - Given a free/
noneorg, then the screen shows the Free plan with an upgrade CTA (reuses F72 entitlements), not an error.
- Given an active subscription, when I open Settings → Subscription, then plan name, price, billing period, status, current-period start/end, and
-
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
PaymentIntentfor 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.
- 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
-
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=falseand billing resumes normally. - Given a cancellation, an audit log row (F16) and a confirmation email (F35) are written.
- Given an active subscription, when I cancel, then by default it is set
-
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 anInvoiceis 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.
- Given a successful
-
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).
- Given a member, then the subscription screen is read-only: no change/cancel/invoice-download controls; shows “Contact {owner} to manage billing” (reuses F72
-
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.
- 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
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);Invoicemodel + 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) — addcancel_at_period_end: BooleanField(default=False). No new status value; the F24 lifecycle (trialing/active/grace/read_only/cancelled) is unchanged. Whencancel_at_period_endand the period expires, the existingtransition_expired()path runs (grace→read_only), andactivate_subscriptionis not re-invoked.PlanChange(new,UUIDModel,organizationFK) — 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,organizationFK,payment_intentFK nullable,subscriptionFK 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 sharedactivate_subscriptionpath 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).
| Method | Path | Role | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | subscription/ | member | — | F24 state + cancel_at_period_end, scheduled_change | — |
| POST | change-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 |
| POST | cancel/ | owner/admin | {immediate?: bool=false} | {cancel_at_period_end, access_until} | 403 · 409 no active sub |
| POST | reactivate/ | owner/admin | — | {cancel_at_period_end:false} | 403 · 409 not scheduled to cancel |
| GET | invoices/ | owner/admin | — | [{id, number, issued_at, total, currency, status}] (keyset paginated) | 403 |
| GET | invoices/{id}/ | owner/admin | — | full invoice incl. line_items | 403 · 404 (cross-org ID → 404, never 403-leak) |
| GET | invoices/{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 F24BillingCard, not a new page. - Components: shadcn
Card,Button,Badge,Dialog(confirm cancel/downgrade),Table(invoice history via F66DataTable),AlertDialogfor destructive cancel. Checkfrontend/src/components/ui/first; reuseGraceBanner,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
billingnamespace —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+vitogether (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
| # | Case | Type | Where |
|---|---|---|---|
| 1 | cancel/ sets cancel_at_period_end, access until period end, data preserved | backend | backend/tests/test_billing_management.py::TestCancel |
| 2 | reactivate/ clears scheduled cancel | backend | same |
| 3 | Upgrade computes proration credit, creates net PaymentIntent | backend | TestChangePlan::test_upgrade_proration |
| 4 | Downgrade scheduled at period end; entitlements unchanged until then | backend | TestChangePlan::test_downgrade_scheduled |
| 5 | Same-plan change → 200 no-op | backend | TestChangePlan::test_noop |
| 6 | Invoice generated on every gateway’s activate_subscription | backend | TestInvoice::test_generated_on_payment |
| 7 | Per-org sequential invoice numbers, no gaps under concurrency | backend | TestInvoice::test_sequential_numbers |
| 8 | Cross-tenant: org B cannot read/download org A’s invoice (404), cancel A doesn’t touch B | backend | backend/tests/test_multitenancy.py |
| 9 | Member cannot cancel/change/download (403); can read subscription | backend | TestBillingRoles |
| 10 | Invoice download returns PDF/HTML; cross-org id → 404 | backend | TestInvoice::test_download |
| 11 | Settings → Subscription: manage controls (owner), read-only (member), invoice table renders | e2e | frontend/tests/e2e/lumi.spec.ts describe("Subscription Management") |
| 12 | A changed plan request through the public pay endpoint replaces an incompatible pending intent without violating the one-pending-per-org/gateway constraint | backend | TestPayEndpoint::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
| Error | User message (i18n) | HTTP | Logged |
|---|---|---|---|
| Member attempts manage action | billing.error_not_permitted | 403 | No |
| Cancel with no active subscription | billing.error_no_active_sub | 409 | No |
| Reactivate when not scheduled to cancel | billing.error_not_scheduled | 409 | No |
| Change to same plan+period | (none — success no-op) | 200 | No |
| Invalid plan/period | billing.error_invalid_plan / billing.error_invalid_period | 400 | No |
| Cross-org invoice/sub id | generic not-found (no existence leak) | 404 | Yes (server) |
| Invoice PDF generation failure | billing.error_invoice_unavailable; fall back to HTML receipt | 200/502 | Yes |
| Gateway failure on upgrade charge | reuse F24 gateway error states (error_gateway_*) | 502/503 | Yes |
| Changed request while a gateway payment is pending | prior intent becomes cancelled; return a fresh checkout for the latest plan/period/seats | 200 | No |
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.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Subscription.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 |
| 2 | cancel/, reactivate/, change-plan/, invoices/, invoices/{id}/, invoices/{id}/download/ | ✅ | backend/apps/billing/views.py + urls.py |
| 3 | change_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 |
| 4 | Invoice generated in shared activate_subscription; race-safe select_for_update numbering | ✅ | services/__init__.py::generate_invoice, _next_invoice_number |
| 5 | Cancel writes F16 audit + F35 email; reactivate writes audit | ✅ | services.py |
| 6 | Tenant isolation: cross-org invoice → 404; per-org numbering; member 403 | ✅ | tests/test_billing_management.py, tests/test_multitenancy.py::TestBillingManagementTenantIsolation |
| 7 | Web: SubscriptionManagement (change/cancel/reactivate + invoice table/download), hooks, en+vi i18n | ✅ | frontend/src/components/billing/SubscriptionManagement.tsx, hooks/queries/useBilling.ts, api/endpoints.ts |
| 8 | Settings 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 |
| 9 | E2E 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):
- Runtime proof — backend and Vitest targets are registered, but execution depends on local runner environments; the Playwright journey remains unverified.
- PDF invoices — v1 download returns an HTML receipt; PDF deferred per §12 (no heavy dep yet).
- Native (iOS/Android/Mac) — management UI not ported to mobile shells → platform cells
🔲. - 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-1Trecorded the same one-pending-intent failure family.
Tests:
TestPayEndpoint.test_changed_plan_replaces_incompatible_pending_intentreproduces 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()inservices/__init__.pywere readingPlan.price_for_period()/Plan.monthly_price(DEPRECATED since ADR-024 §3 / P29). In production all plans havemonthly_price=0(seeseed_plans.py), so every plan-change comparison evaluated0 >= 0 → is_upgrade=True, misclassifying every downgrade as an immediate upgrade and returningproration_credit=0/amount=0for all plan changes.- Fixed both functions to use
bands.price_for(org, plan, period)(same authoritative source ascreate_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_creditandamountin upgrade response dicts. create_payment_intentwas not changed — the actual charge was always correct.- Postmortem:
docs/postmortems/2026-07-02-change-plan-deprecated-pricing.md.
Tests:
- Added
TestChangePlanWithPlanPriceclass intests/test_billing_management.py. Fixtures usemonthly_price=0+PlanPricerows (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), andInvoicegeneration/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
billingendpoints +Invoice/PlanChangemodels +Subscription.cancel_at_period_end(append-only migration). Extends F24’sBillingCardUI. F24 §3 updated to delegate invoices/proration here.
Tests:
- To be added with implementation (see §7):
test_billing_management.py,test_multitenancy.pyrows, E2Edescribe("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
SettingsPagehid 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
/settingssurface.
Tests:
pnpm --dir frontend exec vitest run src/test/SettingsPage.test.tsx src/test/subscription-management.test.tsxpnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "Subscription Management" --project=chromiumpnpm --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
IntegrityErrorinstead 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_intentreproduces SentryLUMI-BE-1Tand proves the old intent is cancelled, the replacement is pending, and only one pending row remains.