CODEX // oaoisme wiki

§ unswayed-backend

Subscriptions & billing (Phase 10)

updated 2026-06-10

Subscriptions & billing (Phase 10)

Phase 10 is the paid-plan lifecycle on Stripe — eleven authenticated endpoints under §44, one public webhook under §45, and a daily cron. It lives in src/subscriptions/; ADR-0034 records the decisions.

There is no subscriptions table

The single most load-bearing decision is inherited and embraced: the active subscription state lives on User — nine denormalized columns (subscription_plan holds the plan name: Freemium, 30-Day Trial, Expired, or a real plan name; trial and period bounds; the Stripe ids; a JSON array of reminder-dedupe keys) — and every state change is audited in the append-only SubscriptionHistory (with a denormalized plan_name so history survives plan deletion). The legacy codebase also contained a subscriptions table and model with zero references — dead scaffolding, deliberately not ported. Every "what is this user's subscription?" read flows through one SubscriptionInfoService, which ports the legacy HasSubscription trait exactly: the frozen 7-key wire shape, whole-day-floored days_remaining, trial-versus-paid branching.

Plans are reference data: the exact 9 legacy rows (3 applicant, 6 employer; monthly and yearly variants share a name and differ by slug + billing_period) seed via the idempotent seed:subscription-plans CLI. Two pricing subtleties are frozen: price is display-only — what Stripe actually charges is stripe_unit_amount in cents, with the 15% annual discount baked into the yearly amounts (e.g. 50990, 178500), not a coupon — and every serialized plan appends a stripe_amount alias the frontend depends on.

One port to Stripe, with the version pinned

StripeService (behind the abstract BillingPort) is the only file that knows the SDK. The client is built lazily with the API version pinned to the SDK's own literal — typed so an SDK upgrade that moves the pin becomes a compile error, not a silent behavior change. That matters because of the phase's flagship fix: the legacy read current_period_start/end off the top-level subscription object, but newer Stripe API versions moved those onto items.data[0] — so subscription dates silently came back null. The rebuild's period mapper reads item-level first with a top-level fallback, fixture-tested against both shapes; under the pinned version the item-level read is load-bearing.

Everything else ports verbatim: the lazy customer (metadata user_id/user_type is the real linkage — the cosmetic name is omitted since the rebuilt User has no name columns), the checkout session (the ?session_id={CHECKOUT_SESSION_ID} template, remaining trial days carried into Stripe, the June-promo coupon gate), both cancel branches, the prorated plan swap, the card mapping, SetupIntents, the Billing Portal. Unconfigured keys are a feature: every Stripe-touching endpoint degrades to its exact legacy 500 string, so the app boots and the entire test pyramid runs without credentials.

The eleven endpoints, and a third pagination dialect

/api/subscriptions/{plans,current,history,checkout,verify-checkout,cancel,resume, change-plan,customer-portal,payment-methods,setup-intent} — all behind JWT plus the account-status guard, both user types (plans filter by the caller's type; buying a plan for the wrong type is the exact 403 This plan is not available for your account type.). Notable frozen shapes:

  • history reproduces the full Laravel API-resource paginatordata/links{first,last,prev,next}/meta{current_page,from,last_page,links[],path, per_page,to,total}, complete with the UrlWindow page-link slider and « labels. That's the third pagination dialect in the contract (after the repo-wide pagination block and §7/§8's meta), isolated in one reusable laravelPaginate helper. History items carry amount_paid as a 2-decimal string and the nested subscription_plan relation.
  • verify-checkout is the idempotent convenience fallback — it activates only when the session's subscription id differs from the user's stored one; the webhook remains the source of truth.
  • cancel finally persists the reason the legacy validated and then threw away (into SubscriptionHistory.notes, on the immediate branch — at-period-end writes no row; the eventual webhook deleted event owns it).

The webhook: raw bytes, raw bodies, replay-safe

POST /api/stripe/webhook is public and signature-verified over the raw request bytes (rawBody: true app-wide). Its §45 response contract deliberately bypasses the standard envelope: once the signature verifies it always answers the raw 200 {"status":"success"}including unhandled event types — and only a signature failure gets 400 {"error":"Invalid signature"}. Seven events drive the exact legacy mutations and history rows (active, upgraded, cancelled, renewed with the cents→dollars conversion). Two legacy TODO stubs are now real: invoice.payment_failed queues a dunning email and trial_will_end a trial-ending email — both through the Phase 1 mail queue, best-effort, never breaking the 200.

The rebuild adds event-id idempotency (ProcessedStripeEvent): a Stripe redelivery of an already-processed event skips cleanly (still 200), and the id is recorded only after successful processing — so a handler failure stays retryable.

The cron that never ran

The legacy shipped a subscriptions:check-expirations command — with reminder thresholds, dedupe keys, downgrade logic — and never scheduled it. No reminder ever fired in production; no lapsed trial was ever downgraded automatically. The rebuild registers a real @nestjs/schedule daily job (09:00 UTC) running an idempotent SubscriptionExpiryService: 14/7/3/1-day reminders (keys 14_days/7_days/3_days/24_hours, marked sent before dispatch, one per user per run, queued email with the dynamic subject), expired trials → Expired, expired non-Stripe paid subs → Expired/Freemium by type — and Stripe-managed subscriptions left strictly alone, because the webhooks own them.

Auth integration, and a cross-phase fix

Employer registration (manual and social) now seeds the 30-day trial — the user fields plus a status: 'trial' history row with the exact legacy notes — and answers with the 3-key {plan, trial_ends_at, days_remaining: 30} shape. Applicant registration keeps subscription: null (the frozen §3 sample governs). Every login, social login, and complete-profile response embeds the full 7-key info object.

Building this phase also surfaced a gap worth recording: the legacy wrapped every authed route group in checkAccountStatus, but the Phase 7–9 controllers had shipped with the JWT guard only — a paused or deactivated account could still post, chat, and share. The AccountStatusGuard sweep (ADR-0034 §7) closed that across all twelve affected controllers; a non-active account now answers 403 Account is not active everywhere, as legacy did.

Testing

Six TDD slices (core → Stripe port → endpoints, webhook, cron, auth integration, all at 100% file coverage with a fully mocked SDK); two e2e suites — subscriptions (keyless 500 contract, the Laravel paginator deep-equals, trial-on-register) and stripe-webhook (real offline signatures via the SDK's test-header helper, all seven events, replay idempotency, the raw bodies) — through the adversarial author→review→patch pipeline; and a Postman 19-subscriptions folder (the run boots with the seeded 9 plans via the CLI).

Subpages