partial — multi-gateway backend and store receipt replay protection shipped; real App Store / Play provider verification remains blocked

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/none org 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 the entitlements block on GET /api/billing/subscription/ (added by F72). The lapsed-payer grace → read_only lifecycle 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 reason recorded — never silently dropped, never auto-guessed.
  • 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.completed webhook 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.COMPLETED webhook 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.succeeded webhook 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 same Subscription model activates via the shared payment-to-subscription path. Receipt intake and replay protection ship, but verify_store_purchase is 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 409 and refuses to transfer entitlement.
  • 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.

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)

FieldTypeNotes
currencyCharField(3) default VNDVND for Sepay/Airwallex; USD for PayPal
external_payment_urlTextField blankStripe/PayPal/Airwallex checkout redirect URL
external_payment_idCharField(255) blankStripe session ID / PayPal order ID / Airwallex intent ID

Gateway values

GatewayRailCurrencyRedirect?Offered?
sepay_qrVietQR bank transferVNDNo — QR scan✅ Vietnam only
airwallexAirwallex hosted card checkout (Visa/MC/Amex)VND (single global book; Airwallex settles cards)Yes✅ all markets (primary card rail)
paypalPayPal Orders (balance or linked card)VND book (PayPal converts at checkout)Yes✅ all markets
freeNo charge—No✅ (free plan / promo)
stripeCard (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

MethodPathMin roleNotes
GET/api/billing/subscription/org memberPlan, 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 admingateway param selects rail (default: sepay_qr); returns qr_url+bank_info for Sepay, checkout_url for others
POST/api/billing/store/verify/owner or adminVerifies App Store / Play purchase and activates the same Subscription truth. Body: `{platform, product_id, receipt_data
GET/api/billing/payment/{code}/status/ownerPayment status poll
GET/api/billing/payments/ownerPayment history

Webhooks (unauthenticated by DRF, authenticated per-gateway):

MethodPathGateway 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 idplan_codeseatsperiod
lumi_ai_pro_5_monthlyai_pro5monthly
lumi_ai_pro_5_annualai_pro5annual
lumi_ai_pro_10_monthlyai_pro10monthly
lumi_ai_pro_10_annualai_pro10annual
lumi_ai_pro_20_monthlyai_pro20monthly
lumi_ai_pro_20_annualai_pro20annual
lumi_ai_pro_50_monthlyai_pro50monthly
lumi_ai_pro_50_annualai_pro50annual

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: billing namespace (new), en+vi.

7. Test Plan

#CaseTypeWhere
1Sepay: intent → webhook → subscription activation; idempotent duplicate webhookbackendbackend/tests/test_billing.py
2Sepay: amount mismatch → logged+ignored; wrong prefix → ignoredbackendsame
3Lifecycle: expiry → grace → read-only; mutation blocked in read-onlybackendTestSubscriptionLifecycle
4Member sees entitlement truth without purchase rights; owner/admin can manage billingbackendTestSubscriptionEndpoint, TestPayEndpoint
5Stripe: billing_pay with gateway=stripe calls StripeService.create_checkout; returns checkout_urlbackendTestStripeGateway
6Stripe: checkout.session.completed webhook activates subscriptionbackendsame
7Stripe: invalid signature → 401backendsame
8PayPal: billing_pay with gateway=paypal calls PayPalService.create_checkout; returns checkout_urlbackendTestPayPalGateway
9PayPal: PAYMENT.CAPTURE.COMPLETED webhook activates subscriptionbackendsame
10PayPal: invalid verification → 401backendsame
11Airwallex: billing_pay with gateway=airwallex; returns checkout_urlbackendTestAirwallexGateway
12Airwallex: payment_intent.succeeded webhook activates subscriptionbackendsame
13Airwallex: invalid signature → 401backendsame
14Invalid gateway value → 400backendTestBillingPay
15create_payment_intent with each gateway stores correct currencybackendTestPaymentIntentGateway
16WebhookLog records every incoming webhook (matched + unmatched) for all gatewaysbackendTestWebhookLogging
17Owner sees billing card with the offered gateway selector and plan buttons; member sees no billing carde2etest.describe('Billing (F24)', ...) in frontend/tests/e2e/billing.spec.ts
18Gateway factory raises on unknown gatewaybackendTestGatewayFactory
19Sepay QR selected without SEPAY_ACCOUNT_NUMBER/SEPAY_BANK configured → 503 (not 500)backendTestSepayServiceEdgeCases::test_sepay_unconfigured_returns_503
20Store verify activates subscription, is idempotent for same org, rejects cross-org replay and unknown productsbackendTestStoreVerifyEndpoint

Backend coverage must stay at 100%.

8. Current Behavior

  • apps.billing app registered; Plan, Subscription, PaymentIntent, WebhookLog models migrated.
  • StoreReceipt model and replay protection are present; POST /api/billing/store/verify/ activates Subscription via 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::BlockReadOnlyOrg in 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

ErrorUser messageHTTPLogged
Unknown gatewaybilling.error_invalid_gateway400No
Gateway not configured (missing env)billing.error_gateway_unavailable503Yes (server)
Gateway API timeoutbilling.error_gateway_timeout502Yes
Amount mismatch (Sepay)recorded in WebhookLog.error_message400Yes
Invalid webhook signature—401Yes (WebhookLog)
Already paid (idempotency)—200 already_paidYes
Intent expired—200 expiredYes
#ErrorUser messageHTTPLoggedNotes
ES1Invalid billing period — payload sends "billing_period": "weekly"billing.error_invalid_period400NoSerializer choice field: monthly, annual, pilot_14, pilot_30
ES2Cross-org payment code collision — same LU code in two orgs’ payment intents— (internal, webhook path)200 ignoredYes (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.
ES3Admin cannot pay — admin calls POST /api/billing/pay/billing.error_not_permitted403NoOwner-only. TestPayEndpoint::test_admin_cannot_create_payment_intent.
ES4Gateway service unavailable — missing env var for requested gatewaybilling.error_gateway_unavailable503Yes (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.
ES5Store receipt replay for different org — same App Store transaction used for org A then org Bbilling.error_receipt_already_used with detail409Yesverify_store_purchase() checks existing StoreReceipt with same (platform, transaction_id).
ES6Free plan re-purchase — user calls POST /api/billing/pay/ with free planFree plan auto-activates; no QR/checkout URL returned200NoFree 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.

#ClaimStatusEvidence
1apps/billing Django app✅ matchbackend/apps/billing/
2POST /api/billing/pay/ with gateway param✅ matchbackend/apps/billing/views.py
3Webhook endpoints /webhook/billing/{sepay,stripe,paypal,airwallex}/✅ matchbackend/apps/billing/views.py
4Plan 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✅ matchbackend/apps/billing/models.py; backend/apps/billing/management/commands/seed_plans.py; backend/apps/accounts/management/commands/seed_demo_lumi.py
5Frontend billing UI (BillingCard) at /settings✅ matchfrontend/src/components/billing/BillingCard.tsx
6E2E: 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)✅ matchfrontend/tests/e2e/billing.spec.ts; frontend/tests/e2e/p29-billing-global-book.spec.ts
7Backend tests test_billing_api.py + test_billing.py + test_payment_methods.py✅ matchbackend/tests/
8POST /api/billing/store/verify/ (F30 / P13)✅ matchbackend/apps/billing/views.py
9PlanPrice model with band/currency/monthly_amount (P29 PR-1)✅ matchbackend/apps/billing/models.py; seed_plans.py
10resolve_band(org) + currency_for_band() + offered_gateways(org) (P29 PR-2/PR-4)✅ matchbackend/apps/billing/services/bands.py
11Organization.billing_market field (P29 PR-1)✅ matchbackend/apps/organizations/models.py
12ActiveUserMonth model + compute_active_users() (P29 PR-3)✅ matchbackend/apps/billing/services/metering.py
13Cron HMAC endpoint POST /api/billing/cron/measure-active-users/ (P29 PR-3)✅ matchbackend/apps/billing/billing_public_views.py; contract proof F24.AC006.*
14Stripe removed from offered gateways; sepay_qr gated to VN (P29 PR-4)✅ matchbackend/apps/billing/views.py; billing/services/bands.py
15Frontend BillingCard uses API plans + org-market gateway selector (P29 PR-5)✅ matchfrontend/src/components/billing/BillingCard.tsx
16Cloudflare Cron wiring documented (P29 PR-6)✅ matchdeploy/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_RATE env 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 Plan price columns (MYR/IDR/SAR/PHP/EUR); (2) live-conversion using Organization.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_purchase hashes client proof and returns payload.mode: mock without 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_ultimate seeded at USD $0/$2/$5/$10/AI-user/month via seed_plans.py (ADR-025 per-AI-user USD model). The legacy demo seed (seed_demo_lumi.py) still seeds starter/team/business at VND flat prices for the local demo org only. The previous row claimed only the legacy starter/team/business codes.
  • §11 Row 6 updated: the E2E billing block is test.describe('Billing (F24)', ...) in frontend/tests/e2e/billing.spec.ts, not describe("Billing") in lumi.spec.ts.
  • §11 verdict text updated: “PlanPrice rows seed both bands” was wrong — VALID_BANDS = frozenset([BAND_GLOBAL]) in services/bands.py; only the single global/USD band is seeded; emerging/international are 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 6 com.runlumi.{starter|team|business}_{monthly|annual} ids from the 2026-06-16 PR are explicitly rejected by the resolver with 400 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.
  • Subscription and PaymentIntent gain a seats: int field; the web POST /api/billing/pay/ body accepts seats and 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_alert field 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 seats on 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.py resolver cases for each of the 8 ids; legacy-id rejection; seats field 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/VND PlanPrice per 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_limit always allows). included_active_users / extra_active_user_amount set 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 live meters block (ai_updates_used, storage_used_mb, automation_runs_used).

Implementation (done):

  1. backend/apps/billing/features.py — 5-plan catalog, new flags/limits, build_entitlements + build_meters.
  2. backend/apps/billing/management/commands/seed_plans.py — single VND book, Intel display names.
  3. backend/apps/billing/views.py — meters merged into the entitlements block.
  4. backend/apps/organizations/views.py — seat limit removed (unlimited users).
  5. frontend/src/hooks/queries/useEntitlements.ts — new flags/limits/meters + usageFraction().
  6. 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: PlanPrice model (band/currency/monthly_amount/annual_amount/included_active_users/extra), billing_market on Organization, seed_plans management command
  • PR-2: resolve_band(org) + currency_for_band() + _BAND_CURRENCIES mapping; /api/plans/ returns band-locked pricing for authenticated org requests
  • PR-3: ActiveUserMonth model + compute_active_users() + POST /api/billing/cron/measure-active-users/ HMAC endpoint
  • PR-4: Stripe removed from offered gateways (billing_pay rejects 400); offered_gateways(org) gates sepay_qr to VN only; dynamic default gateway per org market
  • PR-5: BillingCard uses /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 (formerly p29-billing-bands.spec.ts; verifies single global band, included_active_users == 0, gateway rules, cron HMAC); Cloudflare Cron topology in deploy/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 emerging band and USD for international; 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 unhandled ValueError/500 when Sepay QR is selected without SEPAY_ACCOUNT_NUMBER/SEPAY_BANK configured — 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 Plan records (starter/team/business) in seed_demo_lumi.py so 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 gateway param to POST /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_id to PaymentIntent model
  • Added migration 0002_paymentiintent_multi_gateway
  • Extracted shared _process_payment_code helper used by all webhook handlers

Reason:

  • International customers and larger accounts need card/PayPal/regional rails beyond VietQR.

Impact:

  • POST /api/billing/pay/ now accepts gateway param (default sepay_qr — fully backward-compatible)
  • Existing Sepay webhook URL /webhook/billing/sepay/ unchanged
  • WebhookLog gateway field now shows gateway name for each log entry

Tests:

  • TestStripeGateway, TestPayPalGateway, TestAirwallexGateway, TestGatewayFactory, TestWebhookLogging added to backend/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.py with 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.organization and 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.