Spec: F24 — Billing (Multi-Gateway)
Status: partial — multi-gateway backend and store receipt replay protection shipped; real App Store / Play provider verification remains blocked Contract: contracts/features/F24.cue Contract audit: F24 billing audit Security-critical: every PR here gets a security review pass before merge (payment code mandatory trigger)
1. Problem & CEO Job
Gating strategy (2026-06-15): Lumi is freemium, not hard-paywalled — see F72. A free/
noneorg has full access to the core workspace; premium features prompt an in-context upgrade. F24 below remains the authoritative contract for payment rails, webhooks, subscription lifecycle, and theentitlementsblock onGET /api/billing/subscription/(added by F72). The lapsed-payergrace → read_onlylifecycle is unchanged and is the only hard gate; it never applies to free users.
Lumi needs revenue. Vietnamese SMEs pay by bank transfer (VietQR via Sepay), which is the primary rail. International customers, larger accounts, and businesses that prefer cards need Stripe; those already in the PayPal ecosystem use PayPal; companies across Southeast Asia often use Airwallex (supports VND natively, popular in the region). Offering one payment rail excludes segments — the multi-gateway strategy lets the payer choose their preferred rail.
The billing UX contract: an owner or admin subscribes in under two minutes without reading instructions, while the rest of the org sees entitlement truth without being shown the wrong purchase surface.
2. User Stories & Acceptance Criteria (v1)
-
As an owner, I want to see my plan and pay by scanning a QR (VietQR), so that payment matches how I already bank.
- Given the billing screen, then current plan, price (VND), and period are visible; “Thanh toán” renders a VietQR with amount + unique transfer memo (
LU+ 8 digits). - Given a matching inbound transfer (amount + memo) received via Sepay webhook, then the subscription activates/extends within minutes and the screen reflects it.
- Given an unmatched or partial transfer, it is logged but ignored with
reasonrecorded — never silently dropped, never auto-guessed.
- Given the billing screen, then current plan, price (VND), and period are visible; “Thanh toán” renders a VietQR with amount + unique transfer memo (
-
As an owner, I want to pay by card (Stripe), so that I can use my corporate card.
- Given I choose Stripe, a Stripe Checkout session is created and I am redirected to complete payment.
- Given payment succeeds on Stripe, the
checkout.session.completedwebhook activates my subscription within minutes.
-
As an owner, I want to pay via PayPal, so that I can use my existing PayPal balance or linked card.
- Given I choose PayPal, a PayPal Order is created and I am redirected to PayPal for approval.
- Given I complete payment on PayPal, the
PAYMENT.CAPTURE.COMPLETEDwebhook activates my subscription.
-
As an owner, I want to pay via Airwallex, so that I can use the regional payment network supporting VND.
- Given I choose Airwallex, a Payment Intent is created and I am redirected to the Airwallex hosted checkout page.
- Given payment succeeds, the
payment_intent.succeededwebhook activates my subscription.
-
As an owner, I want grace, not guillotine: expiry → 7-day grace banner → read-only mode. Never delete data for non-payment.
-
Plans v1: Free / Starter / Team / Business (monthly + annual, 20% annual discount). Pilot offers: 14-day (2.9M VND) and 30-day (9M VND).
-
Plans v2 (ADR-029, 2026-06-25): Free / Intel Starter / Intel Growth / Intel Business / Intel Scale (display names). Plan codes kept stable (
free/starter/team/business/scale) to avoid a migration. All plans include unlimited users. Single global VND price book (“Emerging focus”): 0 / 199k / 499k / 990k / 1,990k — no bands. Billing unit: flat per workspace. See pricing.md §22. -
Every billing state change writes an audit log row (F16).
-
As a mobile customer, I want native store purchases to activate the same org subscription truth as web payments, so that entitlement stays server-side and consistent across every surface.
- Given a verified App Store or Play Billing purchase, when the backend accepts it at
POST /api/billing/store/verify/, then the sameSubscriptionmodel activates via the shared payment-to-subscription path. Receipt intake and replay protection ship, butverify_store_purchaseis still a deterministic mock and cannot establish provider truth. - Given the same store receipt is replayed for the same org, then verification is idempotent; given it is replayed for another org, then the backend returns
409and refuses to transfer entitlement.
- Given a verified App Store or Play Billing purchase, when the backend accepts it at
-
As a multi-org user, I want billing truth to follow the current organization, so that one lapsed company never blocks a paid one.
- Given I belong to org A and org B, when I switch
X-Organization-ID, then/api/billing/subscription/returns the subscription state for that org only. - Given I leave or delete org A, when I switch to org B, then org B keeps its own subscription history and state untouched.
- Given I belong to org A and org B, when I switch
3. Scope Boundaries
- In: plan model, VietQR payment via Sepay, Stripe Checkout, PayPal Orders v2, Airwallex hosted checkout, webhook-based payment confirmation (no polling), subscription lifecycle (active/grace/read-only), billing screen in settings, WebhookLog audit trail, and per-organization billing truth for multi-org accounts.
- Out (v1): self-serve invoices/e-invoice, proration, self-service cancel/upgrade/downgrade lifecycle — delegated to F74 Subscription Management (F74 reuses F24’s rails +
activate_subscription; legally-compliant hóa đơn điện tử remains deferred there too). Also out: seats-based metering, coupons, multi-currency pricing on Plans (all Plans priced in VND; gateways convert), affiliate tracking (pricing.md §10–14 exists but is out of scope for v1 billing code), recurring card billing (Stripe subscriptions — v1 is one-time payment per period).
4. Data Model
New app billing: Plan (global — not tenant-scoped), Subscription (org FK, status: trialing/active/grace/read_only/cancelled, period dates), PaymentIntent (org FK, amount, LU-code, status: pending/success/failed/expired/cancelled, gateway: sepay_qr/free/stripe/paypal/airwallex/apple_store/google_play, currency, external_payment_url, external_payment_id), WebhookLog (raw webhook payload, gateway, matched flag, processed flag), StoreReceipt (org FK, payment_intent FK, subscription FK, platform, product_id, transaction_id, verified payload).
Payment Code Format
LU + 8 digits (cryptographically random, via secrets.randbelow)
Example: LU12345678
New PaymentIntent Fields (added 2026-06-11)
| Field | Type | Notes |
|---|---|---|
currency | CharField(3) default VND | VND for Sepay/Airwallex; USD for PayPal |
external_payment_url | TextField blank | Stripe/PayPal/Airwallex checkout redirect URL |
external_payment_id | CharField(255) blank | Stripe session ID / PayPal order ID / Airwallex intent ID |
Gateway values
| Gateway | Rail | Currency | Redirect? | Offered? |
|---|---|---|---|---|
sepay_qr | VietQR bank transfer | VND | No — QR scan | ✅ Vietnam only |
airwallex | Airwallex hosted card checkout (Visa/MC/Amex) | VND (single global book; Airwallex settles cards) | Yes | ✅ all markets (primary card rail) |
paypal | PayPal Orders (balance or linked card) | VND book (PayPal converts at checkout) | Yes | ✅ all markets |
free | No charge | — | No | ✅ (free plan / promo) |
stripe | Card (Stripe Checkout) | VND (zero-decimal) | Yes | ⚠️ deprecated 2026-06-23 — removed from the offered set (Airwallex covers cards); webhook/service code retained but not surfaced |
Offered payment methods (2026-06-23): PayPal (card), Airwallex (card), and Sepay QR (Vietnam only). Canonical set in pricing.md §19; rationale in ADR-024. Native iOS/Android in-app purchase (
apple_store/google_play) remains where the platform mandates it.
State machines → backend/DIAGRAMS.md. ADR-009 records the webhook auth model per-gateway.
5. API Contract
| Method | Path | Min role | Notes |
|---|---|---|---|
| GET | /api/billing/subscription/ | org member | Plan, status, period, limits, plus can_manage_billing / contact_owner_name for the mobile entitlement gate. Always scoped to current org header |
| POST | /api/billing/pay/ | owner or admin | gateway param selects rail (default: sepay_qr); returns qr_url+bank_info for Sepay, checkout_url for others |
| POST | /api/billing/store/verify/ | owner or admin | Verifies App Store / Play purchase and activates the same Subscription truth. Body: `{platform, product_id, receipt_data |
| GET | /api/billing/payment/{code}/status/ | owner | Payment status poll |
| GET | /api/billing/payments/ | owner | Payment history |
Webhooks (unauthenticated by DRF, authenticated per-gateway):
| Method | Path | Gateway auth |
|---|---|---|
| POST | /webhook/billing/sepay/ | X-API-Key HMAC comparison → SEPAY_WEBHOOK_API_KEY |
| POST | /webhook/billing/stripe/ | Stripe-Signature header → STRIPE_WEBHOOK_SECRET via SDK |
| POST | /webhook/billing/paypal/ | PayPal signature verification API → PAYPAL_WEBHOOK_ID |
| POST | /webhook/billing/airwallex/ | X-Signature HMAC-SHA256 → AIRWALLEX_WEBHOOK_SECRET |
No card data ever touches Lumi. PCI scope = zero.
POST /api/billing/store/verify/ request (iOS / Android)
{
"platform": "ios",
"product_id": "lumi_ai_pro_5_monthly",
"receipt_data": "<StoreKit 2 JWS signed transaction>",
"transaction_id": "1000000123456789"
}
Product ID grammar (App Store Connect — refreshed 2026-06-29, was 6 legacy com.runlumi.* IDs):
lumi_ai_pro_<seats>_<period>
├─ 5 | 10 | 20 | 50 (AI seats; integer)
└─ monthly | annual
The resolver returns a (plan_code, seats, period) triple:
| Product id | plan_code | seats | period |
|---|---|---|---|
lumi_ai_pro_5_monthly | ai_pro | 5 | monthly |
lumi_ai_pro_5_annual | ai_pro | 5 | annual |
lumi_ai_pro_10_monthly | ai_pro | 10 | monthly |
lumi_ai_pro_10_annual | ai_pro | 10 | annual |
lumi_ai_pro_20_monthly | ai_pro | 20 | monthly |
lumi_ai_pro_20_annual | ai_pro | 20 | annual |
lumi_ai_pro_50_monthly | ai_pro | 50 | monthly |
lumi_ai_pro_50_annual | ai_pro | 50 | annual |
Unknown product ids return 400 billing.error_unknown_store_product. The legacy com.runlumi.{starter|team|business}_{monthly|annual} ids from the 2026-06-16 StoreKit 2 PR are explicitly rejected with the same error so a stale app build fails loud rather than silently mis-parsing.
Response: the same _serialize_subscription_state body as GET /api/billing/subscription/, with one delta — it now includes a billing_alert field (null when no alert needed; see F72 v3 §5). The freshly-activated Subscription row carries seats: 5|10|20|50 and the new PaymentIntent records the same seats value for audit.
POST /api/billing/pay/ request
{
"plan_code": "starter",
"billing_period": "monthly",
"gateway": "stripe"
}
POST /api/billing/pay/ response (Stripe/PayPal/Airwallex)
{
"payment_code": "LU12345678",
"checkout_url": "https://checkout.stripe.com/...",
"amount": 1490000,
"currency": "VND",
"gateway": "stripe",
"expires_at": "2026-06-11T12:30:00Z"
}
6. UI / UX
- Billing card inside
/settings(owner-only) — no separate billing portal in v1. - Gateway selector: radio buttons — Airwallex (card) / PayPal / Sepay QR (Vietnam only) (show/hide based on env config; Sepay QR shown only for the Vietnam market; Stripe deprecated 2026-06-23 — no longer offered).
- States: free (upgrade pitch), pending QR (QR + “đang chờ chuyển khoản…” with auto-refresh), redirect (spinner + “đang chuyển đến cổng thanh toán…”), active (period end date), grace (banner everywhere, dismissible daily), read-only (clear unlock path).
- Return URL flow: after Stripe/PayPal/Airwallex payment, user lands at
/settings?billing=success— page shows a success state and polls subscription status. - i18n:
billingnamespace (new), en+vi.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Sepay: intent → webhook → subscription activation; idempotent duplicate webhook | backend | backend/tests/test_billing.py |
| 2 | Sepay: amount mismatch → logged+ignored; wrong prefix → ignored | backend | same |
| 3 | Lifecycle: expiry → grace → read-only; mutation blocked in read-only | backend | TestSubscriptionLifecycle |
| 4 | Member sees entitlement truth without purchase rights; owner/admin can manage billing | backend | TestSubscriptionEndpoint, TestPayEndpoint |
| 5 | Stripe: billing_pay with gateway=stripe calls StripeService.create_checkout; returns checkout_url | backend | TestStripeGateway |
| 6 | Stripe: checkout.session.completed webhook activates subscription | backend | same |
| 7 | Stripe: invalid signature → 401 | backend | same |
| 8 | PayPal: billing_pay with gateway=paypal calls PayPalService.create_checkout; returns checkout_url | backend | TestPayPalGateway |
| 9 | PayPal: PAYMENT.CAPTURE.COMPLETED webhook activates subscription | backend | same |
| 10 | PayPal: invalid verification → 401 | backend | same |
| 11 | Airwallex: billing_pay with gateway=airwallex; returns checkout_url | backend | TestAirwallexGateway |
| 12 | Airwallex: payment_intent.succeeded webhook activates subscription | backend | same |
| 13 | Airwallex: invalid signature → 401 | backend | same |
| 14 | Invalid gateway value → 400 | backend | TestBillingPay |
| 15 | create_payment_intent with each gateway stores correct currency | backend | TestPaymentIntentGateway |
| 16 | WebhookLog records every incoming webhook (matched + unmatched) for all gateways | backend | TestWebhookLogging |
| 17 | Owner sees billing card with the offered gateway selector and plan buttons; member sees no billing card | e2e | test.describe('Billing (F24)', ...) in frontend/tests/e2e/billing.spec.ts |
| 18 | Gateway factory raises on unknown gateway | backend | TestGatewayFactory |
| 19 | Sepay QR selected without SEPAY_ACCOUNT_NUMBER/SEPAY_BANK configured → 503 (not 500) | backend | TestSepayServiceEdgeCases::test_sepay_unconfigured_returns_503 |
| 20 | Store verify activates subscription, is idempotent for same org, rejects cross-org replay and unknown products | backend | TestStoreVerifyEndpoint |
Backend coverage must stay at 100%.
8. Current Behavior
apps.billingapp registered; Plan, Subscription, PaymentIntent, WebhookLog models migrated.StoreReceiptmodel and replay protection are present;POST /api/billing/store/verify/activatesSubscriptionvia the shared path used by other gateways. The provider adapter remains a deterministic mock, so live purchase, trial, renewal, cancellation, refund, and expiry truth are not shipped.- Multi-gateway backend implemented: Sepay QR, Stripe Checkout, PayPal Orders v2, and Airwallex hosted checkout all create PaymentIntents and verify their respective webhooks.
core/permissions.py::BlockReadOnlyOrgin DRF default permission stack — blocks mutations for read-only orgs.- Subscription lifecycle engine: trialing → active → grace (7d) → read_only (never deletes data).
- Settings billing UI now exposes a gateway selector and redirects card / wallet rails to hosted checkout.
- 100% backend test coverage.
- E2E
test.describe('Billing (F24)', ...)covers the settings billing surface and subscription visibility.
Contract Graph boundary (2026-07-14)
The canonical F24 contract now owns the shipped subscription, payment-intent, webhook, subscription-lifecycle, store-verification, and settings billing surfaces. The contract records backend and unit proof IDs from the existing suites, but does not infer live provider delivery, production credentials, browser execution, or named Android/iOS purchase runtime from source or test collection. Those remain explicit blocking gaps in the Contract Graph audit.
9. Error States
| Error | User message | HTTP | Logged |
|---|---|---|---|
| Unknown gateway | billing.error_invalid_gateway | 400 | No |
| Gateway not configured (missing env) | billing.error_gateway_unavailable | 503 | Yes (server) |
| Gateway API timeout | billing.error_gateway_timeout | 502 | Yes |
| Amount mismatch (Sepay) | recorded in WebhookLog.error_message | 400 | Yes |
| Invalid webhook signature | — | 401 | Yes (WebhookLog) |
| Already paid (idempotency) | — | 200 already_paid | Yes |
| Intent expired | — | 200 expired | Yes |
| # | Error | User message | HTTP | Logged | Notes |
|---|---|---|---|---|---|
| ES1 | Invalid billing period — payload sends "billing_period": "weekly" | billing.error_invalid_period | 400 | No | Serializer choice field: monthly, annual, pilot_14, pilot_30 |
| ES2 | Cross-org payment code collision — same LU code in two orgs’ payment intents | — (internal, webhook path) | 200 ignored | Yes (WebhookLog) | _process_payment_code() scopes select_for_update() by gateway+code; cross-org codes cannot match because PaymentIntent has an org FK. TestSepayWebhook::test_cross_tenant_code_safety. |
| ES3 | Admin cannot pay — admin calls POST /api/billing/pay/ | billing.error_not_permitted | 403 | No | Owner-only. TestPayEndpoint::test_admin_cannot_create_payment_intent. |
| ES4 | Gateway service unavailable — missing env var for requested gateway | billing.error_gateway_unavailable | 503 | Yes (server) | Each gateway service raises ValueError when env vars missing. TestPayEndpoint::test_sepay_unconfigured_returns_503; TestGatewayServiceUnits::test_stripe_create_checkout_raises_on_missing_key. |
| ES5 | Store receipt replay for different org — same App Store transaction used for org A then org B | billing.error_receipt_already_used with detail | 409 | Yes | verify_store_purchase() checks existing StoreReceipt with same (platform, transaction_id). |
| ES6 | Free plan re-purchase — user calls POST /api/billing/pay/ with free plan | Free plan auto-activates; no QR/checkout URL returned | 200 | No | Free plans skip payment — create_payment_intent sets gateway=free and calls activate_subscription directly. |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md (subscription + payment state machines, gateway field) · GLOSSARY (gateway terms) · ADR-009 (webhook auth + gateway choice) · docs/pricing-payment/pricing.md (plan prices).
11. Implementation Audit (2026-06-11; updated 2026-06-23 P29)
Edge Cases Catalog (formerly §14, 2026-06-12; 8 items): trial expiry during active session (BlockReadOnlyOrg 403 on next write, data preserved via DRF transaction), downgrade with features in use (excess data NOT deleted — advisory-only enforcement in v1), payment failure retry (expired intent replaced with fresh one via idempotency), seat count exceeded (advisory only in v1, no middleware enforcement), currency mismatch on gateway fallback (amount from PaymentIntent VND record, rate-drift risk bounded by 15-min expiry), refund on mid-cycle cancel (not automatic in v1 — Stripe dashboard), concurrent webhook race (select_for_update locks PaymentIntent row; duplicate returns 200 already_paid), grace-period re-activation (activate_subscription() cancels previous sub, write access restored). Full catalog in git history.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | apps/billing Django app | ✅ match | backend/apps/billing/ |
| 2 | POST /api/billing/pay/ with gateway param | ✅ match | backend/apps/billing/views.py |
| 3 | Webhook endpoints /webhook/billing/{sepay,stripe,paypal,airwallex}/ | ✅ match | backend/apps/billing/views.py |
| 4 | Plan model: production catalog free/ai_starter/ai_pro/ai_ultimate seeded at USD $0/$2/$5/$10/AI-user/month via seed_plans.py (ADR-025 per-AI-user USD model); legacy demo seed (seed_demo_lumi.py) still seeds starter/team/business at VND flat prices for the local demo org | ✅ match | backend/apps/billing/models.py; backend/apps/billing/management/commands/seed_plans.py; backend/apps/accounts/management/commands/seed_demo_lumi.py |
| 5 | Frontend billing UI (BillingCard) at /settings | ✅ match | frontend/src/components/billing/BillingCard.tsx |
| 6 | E2E: test.describe('Billing (F24)', ...) in billing.spec.ts + P29 in p29-billing-global-book.spec.ts (renamed from p29-billing-bands.spec.ts; now verifies single global band and included_active_users == 0) | ✅ match | frontend/tests/e2e/billing.spec.ts; frontend/tests/e2e/p29-billing-global-book.spec.ts |
| 7 | Backend tests test_billing_api.py + test_billing.py + test_payment_methods.py | ✅ match | backend/tests/ |
| 8 | POST /api/billing/store/verify/ (F30 / P13) | ✅ match | backend/apps/billing/views.py |
| 9 | PlanPrice model with band/currency/monthly_amount (P29 PR-1) | ✅ match | backend/apps/billing/models.py; seed_plans.py |
| 10 | resolve_band(org) + currency_for_band() + offered_gateways(org) (P29 PR-2/PR-4) | ✅ match | backend/apps/billing/services/bands.py |
| 11 | Organization.billing_market field (P29 PR-1) | ✅ match | backend/apps/organizations/models.py |
| 12 | ActiveUserMonth model + compute_active_users() (P29 PR-3) | ✅ match | backend/apps/billing/services/metering.py |
| 13 | Cron HMAC endpoint POST /api/billing/cron/measure-active-users/ (P29 PR-3) | ✅ match | backend/apps/billing/billing_public_views.py; contract proof F24.AC006.* |
| 14 | Stripe removed from offered gateways; sepay_qr gated to VN (P29 PR-4) | ✅ match | backend/apps/billing/views.py; billing/services/bands.py |
| 15 | Frontend BillingCard uses API plans + org-market gateway selector (P29 PR-5) | ✅ match | frontend/src/components/billing/BillingCard.tsx |
| 16 | Cloudflare Cron wiring documented (P29 PR-6) | ✅ match | deploy/DIAGRAMS.md §7 |
Verdict: P29 billing infrastructure fully implemented. ADR-024 §1–§4 contracts met: band resolution locked by org market, single global/USD band seeded (one PlanPrice row per plan — emerging/international are deprecated model constants, never seeded), Stripe retired from offered set, active-user metering cron documented and endpoint live.
12. Open Questions / Decisions
- PayPal VND→USD rate: use
PAYPAL_VND_TO_USD_RATEenv var (default 25000). No Plan-level USD price in v1. - International pricing currency (F69 pre-condition): All Plans are priced in VND. When F69 activates Malaysian, Indonesian, Saudi, Filipino, and Latvian users, they see VND pricing — disorienting for a CEO in Kuala Lumpur. Three options: (1) per-currency
Planprice columns (MYR/IDR/SAR/PHP/EUR); (2) live-conversion usingOrganization.default_currency(see F27 §10 Q1); (3) VND-only with local equivalent shown. Decision required before F69 ships. - Airwallex VND support: confirmed — Airwallex supports VND as a native currency.
- Stripe VND: confirmed — VND is a zero-decimal currency in Stripe (amount in whole VND).
- Recurring billing (Stripe Subscriptions): deferred to v2; v1 is one-time payment per period.
- Store receipt verification is now shipped as the F30 / P13 PR-0 backend delta:
POST /api/billing/store/verify/verifies the store purchase boundary and activates the same server-side subscription truth used by every other billing rail.
13. Bug / Change Log
2026-07-21 — Store verification truth corrected during native trial work
Type: Audit correction
Summary:
- The repository previously described the store verification delta as complete even though
verify_store_purchasehashes client proof and returnspayload.mode: mockwithout calling Apple or Google. - Receipt intake, product allowlisting, idempotency, and cross-organization replay protection remain real. Provider authenticity and lifecycle reconciliation do not.
- Production activation of the seven-day store offers is blocked until verified provider APIs and notifications own trial status and expiry. See the store runbook.
2026-07-14 — Contract Graph migration slice
Added the canonical F24 contract, runner-native proof annotations, a feature audit, and a focused test plan. No billing behavior changed. The contract deliberately retains live gateway, store sandbox/device, and browser runtime gaps.
2026-07-02 — §11 audit corrected: plan codes, E2E file path, and band count
Type: Documentation drift fix
Summary:
- §11 Row 4 updated: the production plan catalog is
free/ai_starter/ai_pro/ai_ultimateseeded at USD $0/$2/$5/$10/AI-user/month viaseed_plans.py(ADR-025 per-AI-user USD model). The legacy demo seed (seed_demo_lumi.py) still seedsstarter/team/businessat VND flat prices for the local demo org only. The previous row claimed only the legacystarter/team/businesscodes. - §11 Row 6 updated: the E2E billing block is
test.describe('Billing (F24)', ...)infrontend/tests/e2e/billing.spec.ts, notdescribe("Billing")inlumi.spec.ts. - §11 verdict text updated: “PlanPrice rows seed both bands” was wrong —
VALID_BANDS = frozenset([BAND_GLOBAL])inservices/bands.py; only the singleglobal/USD band is seeded;emerging/internationalare deprecated model constants retained for migration compatibility only.
Note: the 2026-06-25 §13 entry’s “single global VND price book” accurately described the ADR-029 flat-VND model at that point in time. The subsequent ADR-025 per-AI-user USD transition (seed_plans.py now seeds USD prices) makes that entry historically correct but no longer the current state. The §11 audit rows above now reflect the shipped code.
2026-06-29 — Store-verify contract refreshed for ASC lumi_ai_pro_* products + seats field
Type: Spec Update (implementation lives in r-billing-iap)
Summary:
- §5 (API Contract) now documents the 8 real App Store Connect product IDs in the
lumi_ai_pro_{5|10|20|50}_{monthly|annual}shape. The legacy 6com.runlumi.{starter|team|business}_{monthly|annual}ids from the 2026-06-16 PR are explicitly rejected by the resolver with400 billing.error_unknown_store_product(fail loud, no silent mis-parse of stale app builds). - A new dedicated subsection describes the resolver grammar and the lookup table.
SubscriptionandPaymentIntentgain aseats: intfield; the webPOST /api/billing/pay/body acceptsseatsand computes per-seat × seats for the per-AI-user model (ADR-024 §3).- Response shape of the store-verify endpoint now also returns the
billing_alertfield added by F72 v3.
Reason:
- App Store Connect product configuration changed 2026-06-29 from the flat starter/team/business set to a per-AI-seat set on the AI Pro plan; the resolver, iOS product IDs, and shared KMP plan-code map all need to follow.
Impact:
- F24 §5 only in this spec change; behavior change lives in the r-billing-iap agents’ PRs (agent A: backend resolver + models + view; agent B: iOS StoreKitManager + PaywallView; agent C: KMP shared LumiDtos + plan-code map; agent D: web
seatson the per-seat checkout form).
Tests:
- None. Spec change only; the r-billing-iap agent A writes failing tests first per TDD (
backend/tests/test_billing.pyresolver cases for each of the 8 ids; legacy-id rejection;seatsfield on the activated Subscription).
2026-06-25 — v2 plan structure: Lumi Intel plans + unlimited users (ADR-029)
Type: Behavior Change (commercial strategy)
Summary:
- Plan codes kept stable (
free/starter/team/business/scale) to avoid a data migration; “Intel” branding is display-name only (Intel Starter/Growth/Business/Scale). - Single global VND price book (“Emerging focus”): one
global/VNDPlanPriceper plan — 0 / 199k / 499k / 990k / 1,990k. Two-band split (ADR-024) dropped. - All plans include unlimited users; the free-tier 3-seat cap is removed (
_check_seat_limitalways allows).included_active_users/extra_active_user_amountset to 0 (headcount never billed). - Free tier: CRM (
crm:true), unlimited projects, unlimited users. Limits: 50 AI updates/mo, 500 MB storage, 5 automation runs/mo, 2 teams, 1 branch. - Entitlements contract gains
limits(ai_updates_monthly, storage_mb, automation_runs_monthly, max_teams, max_branches) and a livemetersblock (ai_updates_used,storage_used_mb,automation_runs_used).
Implementation (done):
backend/apps/billing/features.py— 5-plan catalog, new flags/limits,build_entitlements+build_meters.backend/apps/billing/management/commands/seed_plans.py— single VND book, Intel display names.backend/apps/billing/views.py—metersmerged into the entitlements block.backend/apps/organizations/views.py— seat limit removed (unlimited users).frontend/src/hooks/queries/useEntitlements.ts— new flags/limits/meters +usageFraction().- Backend billing/pricing/seat-limit/active-user-meter tests + frontend entitlements test updated — all green.
Follow-ups (not blocking): visible 80%/100% soft-limit banner UI; pricing-page marketing copy; locale plan-string sweep.
Reason:
- ADR-029: giving away the collaboration layer (tasks/projects/CRM/unlimited users) and charging for Lumi Intel reduces CAC by making adoption a no-brainer for the whole company. A single price book maximises simplicity.
2026-06-23 — P29 complete: per-active-user billing + PPP bands + gateway reset (PR-1 through PR-6)
Type: Feature (P29 — ADR-024 full implementation)
Summary:
- PR-1:
PlanPricemodel (band/currency/monthly_amount/annual_amount/included_active_users/extra),billing_marketonOrganization,seed_plansmanagement command - PR-2:
resolve_band(org)+currency_for_band()+_BAND_CURRENCIESmapping;/api/plans/returns band-locked pricing for authenticated org requests - PR-3:
ActiveUserMonthmodel +compute_active_users()+POST /api/billing/cron/measure-active-users/HMAC endpoint - PR-4: Stripe removed from offered gateways (
billing_payrejects 400);offered_gateways(org)gates sepay_qr to VN only; dynamic default gateway per org market - PR-5:
BillingCarduses/api/plans/data (band-aware prices + currency); gateway selector shows only offered gateways per org market - PR-6: E2E tests
p29-billing-global-book.spec.ts(formerlyp29-billing-bands.spec.ts; verifies singleglobalband,included_active_users == 0, gateway rules, cron HMAC); Cloudflare Cron topology indeploy/DIAGRAMS.md §7; audit updated
Tests: 3732 backend (97.91% cov), 10163 frontend, 65+ E2E. Coverage above 95% threshold.
2026-06-23 — Payment methods narrowed: PayPal + Airwallex (card) + Sepay QR (Vietnam only); Stripe deprecated
Type: Behavior Change (offered payment set; direction — not yet stripped from code)
Summary:
- The offered self-serve payment methods are now exactly PayPal (card), Airwallex (card), and Sepay QR
(Vietnam only). Stripe is removed from the offered set — Airwallex already covers global cards, so
a second card processor is redundant. Stripe service/webhook code is retained (not deleted) but no
longer surfaced in the gateway selector or
/api/plans/checkout options. - Sepay QR is restricted to the Vietnam market (it is VietQR bank-transfer; the dominant, cheapest
SME rail there). Airwallex is the primary card rail in every market, settling in local currency for the
emergingband and USD forinternational; PayPal is the alternative everywhere. - Updated §4 gateway table (Offered? column + Stripe ⚠️ deprecated) and §6 gateway selector.
Reason:
- Aligns billing with the global no-brainer pricing reset and the band model (pricing.md §16/§19, ADR-024): one global card processor (Airwallex) + PayPal + a VN-only QR rail covers all bands without redundancy.
Impact:
- Payers: Vietnam sees Sepay QR + Airwallex + PayPal; other markets see Airwallex + PayPal. No payer loses a usable rail. Implementation follow-up (frontend selector + plan checkout options) tracked for the pricing-reset build pass; Apple/Google IAP unaffected (platform-mandated for native purchases).
Tests:
- Spec-only direction entry; existing Stripe tests remain valid until the surface is removed in code.
2026-06-11 — E2E coverage + Sepay 503 fix
Type: Bug Fix + Test
Summary:
_build_pay_response()no longer crashes with an unhandledValueError/500 when Sepay QR is selected withoutSEPAY_ACCOUNT_NUMBER/SEPAY_BANKconfigured — now returns 503, matching the other gateways.- Added
describe("Billing")E2E tests: gateway selector shows all 4 options + Starter/Team/Business plans (owner), billing card hidden for members. - Seeded
Planrecords (starter/team/business) inseed_demo_lumi.pyso the billing E2E tests have real plans to render.
Reason:
- Discovered while writing E2E tests: no local/CI environment configures
SEPAY_*env vars, so the default gateway path was untested and would 500 in that state.
Tests:
TestSepayServiceEdgeCases::test_sepay_unconfigured_returns_503(backend/tests/test_billing_api.py)TestSeedCommand.test_seed_creates_billing_plans,test_seed_plans_idempotent(backend/tests/test_api.py)describe("Billing")(frontend/tests/e2e/lumi.spec.ts)
2026-06-11 — Add Stripe, PayPal, Airwallex gateways
Type: Feature
Summary:
- Added
gatewayparam toPOST /api/billing/pay/ - Added Stripe Checkout, PayPal Orders v2, and Airwallex hosted-checkout flows
- Added webhook endpoints
/webhook/billing/{stripe,paypal,airwallex}/ - Added
currency,external_payment_url,external_payment_idto PaymentIntent model - Added migration
0002_paymentiintent_multi_gateway - Extracted shared
_process_payment_codehelper used by all webhook handlers
Reason:
- International customers and larger accounts need card/PayPal/regional rails beyond VietQR.
Impact:
POST /api/billing/pay/now acceptsgatewayparam (defaultsepay_qr— fully backward-compatible)- Existing Sepay webhook URL
/webhook/billing/sepay/unchanged - WebhookLog
gatewayfield now shows gateway name for each log entry
Tests:
TestStripeGateway,TestPayPalGateway,TestAirwallexGateway,TestGatewayFactory,TestWebhookLoggingadded tobackend/tests/test_billing.py
2026-06-12 — Verified cross-tenant billing isolation (criteria lines 40-41)
Type: Test Coverage / Verified
Summary:
- Added
backend/tests/test_red_r19_f.pywith two proof tests for the unstruck cross-tenant isolation criteria in §2. - Both tests PASS against current code, confirming the billing views already filter by
request.membership.organizationand org deletion/leave does not affect sibling org subscriptions. - Criteria §2 lines 40-41 struck as verified.
Reason:
- F24 spec had two untested acceptance criteria. Added red-test coverage to lock in the behavior.
Tests:
test_billing_subscription_cross_tenant_isolation(proof)test_billing_subscription_survives_org_a_membership_removal(proof)
2026-06-12 — Added Edge Cases + Error States sections
Type: Documentation Change
Summary:
- Added §14 Edge Cases (E1–E8): trial expiry mid-session, downgrade-with-features, payment failure retry, seat-count exceeded, currency mismatch, mid-cycle refund, concurrent webhook race, grace-period re-activation.
- Added §15 Error States (ES1–ES6): invalid billing period, cross-org code collision, admin-can’t-pay, gateway unavailable, store receipt replay, free plan re-purchase.
Reason:
- Hardening spec completeness for implementation audits and future contributors.
Impact:
- F24 spec now has documented edge cases with test-coverage annotations.
Tests:
- No code changes. Test gaps noted per row.
2026-06-12 — Replaced MB Bank polling with Sepay webhook
Type: Behavior Change
Summary:
- Replaced MB Bank polling architecture with Sepay webhook push.
- No scheduled polling task; Sepay pushes payment confirmations via
POST /webhook/billing/sepay/.
Reason:
- Webhooks eliminate polling lag and server load; Sepay webhook format is well-documented.
Impact:
- Billing app initial build; no existing behavior changed.
Tests:
TestSepayWebhook(idempotent duplicate, amount mismatch, expired intent, cross-tenant safety)
2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)
Type: Documentation Change
Summary:
- §14 Edge Cases (8 items, E1–E8) absorbed into §11 Implementation Audit EC note.
- §15 Error States additional (6 rows, ES1–ES6) merged into §9 Error States.
- §16 Bug / Change Log (updated 2026-06-12, 3 entries) merged into §13 Bug / Change Log.
Reason:
- Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.
Impact:
- Spec authors and future implementation agents.