Stripe billing — programme plan
Living document: update the status columns, the decision log and the change log in the same commit as the work they describe. Status legend: ✅ done · 🟡 in progress · ⬜ planned · 🚫 dropped · ❓ blocked on a decision.
1. Goal
An installation is owned by one company and exposes several applications. It uses one Stripe token (one Stripe account), configured through the environment exactly like the other providers (Google, GitHub, …) — see D2. Two billing mechanics coexist, deliberately (D1):
- Prepaid credit wallet (already shipped, see the LiteLLM gateway & credit wallet plan) — the user tops up a euro balance and spends it across applications. This is the multi-application token-usage mechanic, metered by the LiteLLM gateway.
- Paid subscription (this programme) — a paid product plan opens access on month N; its metered features are billed at the end of the billing period, i.e. month N+1. This is the per-product mechanic.
An application can delegate the whole billing surface to auth-service (subscription, invoices, usage) through the server-rendered templates mechanism, or present the billing itself by calling auth-service: it reports billable usage through a callback and reads a user's subscription, consumption and invoices through an API scoped to its own application.
Operator and user never touch the Stripe dashboard: the admin interface creates, updates and deletes the Stripe objects (products, prices, meters, subscriptions) and the user pays on Stripe's own Checkout / Billing Portal pages (D4).
2. Architecture
admin UI user (profile / app page)
│ │
plans + prices + meters subscribe / invoices / usage
▼ ▼
┌────────────────────────────── auth-service ──────────────────────────────┐
│ services/billing/ ── Stripe API ──► Products · Prices · Meters │
│ ▲ Customers · Subscriptions │
│ │ Invoices · Checkout · Portal │
│ │ │
│ subscription_plan_prices (fixed | metered) │
│ user_subscriptions (+ stripe_subscription_id, status, period) │
│ billing_customers · billing_usage_* · stripe_invoices (cache) │
└───────────────▲───────────────────────────▲──────────────────────────────┘
│ webhook (invoice.*, subscription.*) │ usage callback
│ │ (POST /api/consumption, metered key)
Stripe webhooks application backend
- auth-service is the source of truth for entitlements (
featuresclaim,user_subscriptions) and for the catalogue (which feature is metered, its unit, its price, its hard cap). - Stripe is the source of truth for money for subscriptions: it aggregates the reported usage and issues the invoice at the end of the period.
- The wallet stays separate: it is not a Stripe subscription. Its top-ups
remain a Stripe Checkout in
paymentmode.
3. Data model (frozen)
This model is frozen here (D3). It is applied incrementally, each table/column landing in the same PR as the code that first uses it: until the first production deploy the consolidated
0000_initial_schema.sqlbaseline is regenerated fromsrc/db/schema.ts(AGENTS.md §8), so there is no delta migration to write. The "introduced in" column names the lot that first adds it.
| Object | Change | Introduced in |
|---|---|---|
billing_customers | New table. owner_type (user|org), owner_id (polymorphic, no FK), stripe_customer_id (unique), unique (owner_type, owner_id). Maps an owner to its lazily-created Stripe Customer. | Lot 0 ✅ |
subscription_plan_prices | Extended. Add billing_type (fixed|metered, default fixed), feature_key (nullable, metered only), unit (nullable, metered only), usage_limit (integer, nullable, metered only; -1 = unlimited), stripe_meter_id (nullable, metered only). Unique (plan_id, feature_key) (NULLs distinct for fixed rows). A row is a Stripe Price; a metered row also owns a Stripe Meter. | Lot 2 ✅ |
user_subscriptions | Extended. Add stripe_subscription_id (unique, nullable), stripe_status (nullable), current_period_start, current_period_end (nullable), cancel_at_period_end (bool, default false), canceled_at (nullable). expires_at stays the entitlement end (manual or Stripe period end). | Lot 1 ✅ |
applications | Extended. Add billing_mode (none|delegated, default none): delegated = the app uses the auth-service template pages. Lot 6 implements two values, not three: an external value would change nothing server-side, since an application that shows billing itself already reads the application API (billing:read). Add it only if a behaviour depends on it. | Lot 6 ✅ |
billing_usage_events | New table. Raw append-only metered reports: application_id, user_id, feature_key, quantity (numeric), billing_period_start, idempotency_key, stripe_meter_event_identifier, created_at. Unique (application_id, idempotency_key). Idempotency + audit for the usage callback. | Lot 4 ✅ |
billing_usage_periods | New table. Aggregate per period, authoritative for the hard cap and for display: (user_id, application_id, feature_key, billing_period_start) PK, quantity (numeric), updated_at. | Lot 4 ✅ |
stripe_invoices | New table. Read cache filled by webhooks: stripe_invoice_id (unique), owner, application_id (nullable), status, currency, amount_due, amount_paid, amount_remaining, period_start, period_end, hosted_invoice_url, invoice_pdf, timestamps. Powers the profile page and the delegated invoice page without a Stripe round-trip. | Lot 5 ✅ |
Plan features JSON. With D3 frozen as "prices are the source of truth",
metered definitions move out of the plan features JSON into
subscription_plan_prices. The JSON keeps only static feature flags, so the
token features claim carries entitlements (booleans, quotas) and never a
mutable usage counter. A metered feature's cap and price are read from the
prices table, not the claim.
4. Decisions
| ID | Decision | Date |
|---|---|---|
| D1 | Two billing mechanics are assumed and independent: the wallet (prepaid euro credit, multi-application token usage, metered by LiteLLM) and the subscription (per-product, paid via Stripe, usage billed at period end). They do not share a balance. | 2026-10-26 |
| D2 | The Stripe token is configured env-only (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET), like the social providers. No admin UI for the secret. | 2026-10-26 |
| D3 | The prices table is the source of truth for metered features (meter + price + cap). The plan features JSON keeps only static flags. Frozen model in §3, applied incrementally per lot. | 2026-10-26 |
| D4 | Stripe's own Checkout and Billing Portal pages handle payment and payment-method management. Auth-service templates are used only for exposure and tracking (subscribe summary, invoices, usage), never for entering card data. | 2026-10-26 |
| D5 | A metered feature's usage_limit is a hard cap: consumption beyond it is refused. -1 means unlimited. pricePerUnit is charged at the end of the Stripe billing period (month N+1); units consumed up to the cap are the billable quantity. | 2026-10-26 |
| D6 | Billable usage is reported through the existing POST /api/consumption endpoint: the route detects that the key is a metered feature of the user's active plan for the calling application and routes it to metering instead of the free metric tables. A new endpoint is opened only if this proves too complex. | 2026-10-26 |
| D7 | Billing pages use Eta, fail-closed (D-D): a broken billing template returns a static 500, never the Vue SPA, because a missing payment page is worse than an explicit error. | 2026-10-26 |
| D8 | Billing owners are user-only for this programme (billing_customers.owner_type already accepts org for later). The gap is tracked by D11. | 2026-10-26 |
| D9 | Stripe objects are created/updated/deleted only through auth-service; the operator never opens the Stripe dashboard. Product/prices/meters are reconciled (create, update, archive) and subscriptions are driven by Checkout + webhooks. | 2026-10-26 |
| D16 | Plan changes never grant a paid plan before Stripe agrees, and superseded subscriptions cannot rewrite the entitlement. A free target applies at once. A live paid subscription changes in place (Stripe prorates it; the plan follows its update event). Any other paid target goes through Checkout, which replaces a free subscription only after the new one is linked, and never replaces a paid one (409 SUB_007). Events of a subscription other than the one linked to the (user, application) row are ignored, on every path (subscription, deletion, invoice). Administrators may stop or resume a subscription at period end; immediate cancellation is not offered. | 2026-10-27 |
| D17 | A metered feature is a price, never a feature. The plan's features hold plain values (string, number, boolean). The billing of a feature by usage, its unit and its cap live in one place: the metered price row, which also drives the Stripe Price and Meter. The Stripe ids are provisioned by auth-service only: create and price requests that carry one, or an unknown field, are refused with 400 (unknown fields used to be silently dropped, and stripeProductId would have let a plan archive a Product it did not own). | 2026-10-28 |
| D18 | Reconciliation is report-first and never deletes. pnpm billing:reconcile --check is a read-only audit; the default repair creates missing objects, relinks unusable ones, adopts any non-cancelled Stripe subscription for an unlinked (user, application) row, re-applies the Stripe subscription state locally and pushes pending meter events. A missing subscription is confirmed with a by-id retrieve and is only recreated with --recreate-missing, never for an inactive row. A superseded subscription is cancelled only with --cancel-superseded and only when the replacement is confirmed active/trialing; an orphan is reported and never cancelled. A deleted Stripe Customer is reported, not recreated. Stripe objects are never deleted, and the scan uses account-wide paginated listings instead of per-user/per-customer calls. | 2026-10-28 |
| D15 | A wallet top-up is also a Stripe invoice: the Checkout session is created with invoice_creation enabled, its metadata (purpose: wallet_topup, userId) linking the receipt back to the wallet owner. Its invoice.* events feed the same cache as subscription invoices, with no application. Verified against Stripe test mode (session accepted with invoice_creation.enabled). | 2026-10-27 |
5. Programme overview
| Lot | Scope | Status |
|---|---|---|
| 0 | Foundations: this plan, BIL_ error family, shared owner helper, billing_customers + billing module skeleton | ✅ |
| 1 | Webhook rewrite: route through setUserSubscription, store Stripe ids/status/period, handle the full status set and invoice events; route tests | ✅ |
| 2 | Customer & catalogue sync: billing_customers service wiring, Product create/update/archive, price + meter provisioning, subscription_plan_prices extension, admin metered UI | ✅ |
| 3 | Subscription lifecycle: Checkout (mode: subscription) + Billing Portal, create/update/cancel, user_subscriptions extension, self-service paid upgrade | ✅ |
| 4 | Metered billing: /api/consumption metered path, Stripe Meters, hard-cap enforcement, billing_usage_* | ✅ |
| 5 | Invoices cache + application read API (subscription, usage, invoices scoped to azp) | ✅ |
| 6 | Delegated template pages (subscribe, invoices, billing-usage), billing_mode, app-config exposure | ✅ |
| 7 | Frontend: real invoice history in the profile, paid upgrade/downgrade, metered usage display, admin subscription/renewal actions | ✅ |
| 8 | Hardening: reconciliation, security review, coverage, docs sync | 🟡 (8.1, 8.2 done; 8.3 partial: billing modules measured above 80 %, the repository-wide ≥ 80 % gate is not met) |
A lot is done only when pnpm build:server, pnpm lint, pnpm format:check,
pnpm test and pnpm test:integration pass.
6. Lot details
Lot 0 — Foundations (✅)
| # | Task | Status |
|---|---|---|
| 0.1 | This plan, with the frozen data model (§3) and the decisions D1–D9 | ✅ |
| 0.2 | BIL_ error family in src/errors.ts, AGENTS.md prefix list, specifications §13 and the error-codes reference | ✅ |
| 0.3 | Shared assertOwnerExists / OwnerType (src/services/owners.ts), reused by the wallet so the polymorphic-owner check is not duplicated | ✅ |
| 0.4 | billing_customers table inlined in the 0000 baseline; cleanDb() updated | ✅ |
| 0.5 | src/services/billing/ module: getOrCreateBillingCustomer (lazy Stripe Customer, idempotent) + unit tests | ✅ |
Lot 1 — Webhook rewrite (✅)
| # | Task | Status |
|---|---|---|
| 1.1 | Handlers moved out of src/routes/stripe-webhook.ts into src/services/billing/webhooks.ts; the route keeps signature verification, stripe_events idempotency and dispatch | ✅ |
| 1.2 | Every state change routed through setUserSubscription (audit + denormalised mirror); the mirror now clears when the subscription is inactive | ✅ |
| 1.3 | user_subscriptions extended (stripe id unique, status, period, cancel flags) with the full Stripe status set: active/trialing are entitlements, every other status (past_due, unpaid, paused, incomplete, canceled, …) is inactive | ✅ |
| 1.4 | Handled: customer.subscription.created/updated/deleted, invoice.paid/payment_succeeded (reactivate) and invoice.payment_failed (dunning failures only). Deviations from the original plan (state at Lot 1; superseded later): checkout.session.completed in subscription mode was first left unhandled as redundant, but is now handled by completeSubscriptionCheckout (it links the new subscription before the free one is replaced, D16); customer.updated has nothing to persist and is not handled; invoice.finalized and every other invoice.* event now only refresh the invoice cache (Lot 5). The subscription status, not the invoice stream, is the authoritative source of entitlement | ✅ |
| 1.5 | Handler + route tests (src/tests/billing-webhook.integration.test.ts, real Postgres): price→plan and product→plan resolution, status flips, cancel, invoice reactivation and dunning, one-off invoice ignored, plus a dispatched POST with idempotent duplicate — closes the current coverage gap | ✅ |
Lot 2 — Customer & catalogue sync (✅)
| # | Task | Status |
|---|---|---|
| 2.1 | subscription_plan_prices extension (billing type, feature key, unit, cap, meter) + admin create/update/delete for metered rows | ✅ |
| 2.2 | Product sync: create and update name/description/metadata; archive the Product when the plan is deleted (fixes today's orphan) | ✅ |
| 2.3 | Price + Meter provisioning: create the Stripe Meter for a metered feature, attach it to the usage Price, archive on delete | ✅ |
| 2.4 | Reconciliation pass for objects drifting from the DB (idempotent, run on demand) | ✅ |
| 2.5 | Admin UI: metered toggle backed by real price/meter rows (replaces the JSON-only editor); fix the doc/code drift on stripePriceId | ✅ |
| 2.6 | Wire getOrCreateBillingCustomer: best-effort, idempotent creation on user creation (never blocks registration) and on the first paid flow, plus a backfill for existing users | ✅ |
| 2.7 | Application lifecycle cascade: create the default free plan's Product on application creation, rename the plans' Products on application rename, archive Products/Prices and deactivate Meters on application deletion | ✅ |
Notes (2.1–2.3): catalogue logic lives in src/services/billing/catalog.ts
(no Stripe calls in the routes); the plan price CRUD moved to
src/routes/admin/plan-prices.ts with shared schemas/helpers in
src/routes/admin/plan-shared.ts. A meter is shared per
(application, feature key) so usage aggregates once per customer regardless
of the plan; it is deactivated when its last price is removed. A fixed price
cannot carry metered fields, a metered price cannot be one-off, and the
feature_key charset matches the consumption keys ([A-Za-z0-9.]).
Deleting a plan now also removes its inactive subscriptions — the Lot 1
webhook keeps cancelled subscriptions, which the RESTRICT FK would otherwise
block. Amount, currency, interval and feature_key are immutable (a change
archives the Stripe price and creates a new row); only name/unit/cap are
updatable. Tasks 2.4–2.7 were delivered afterwards (see the change log).
Pre-test audit (2026-10-27) found three gaps in this lot, all closed by 2.6 and 2.7:
- the default
freeplan created with an application is inserted directly and does not create a Stripe Product (provisionPlanProductis only called from the plan-create route), so a new application starts without a catalogue; getOrCreateBillingCustomeris implemented and tested but has no call site, so no Stripe Customer is created yet;- renaming an application does not rename its plans' Stripe Products, and deleting an application does not archive its plans' Products, archive their Prices or deactivate their Meters (only deleting a plan does).
Lot 3 — Subscription lifecycle (✅)
| # | Task | Status |
|---|---|---|
| 3.1 | services/billing/subscriptions.ts: create/update/cancel a Stripe subscription, metadata.{userId,applicationId,planId} set at creation (removes the manual-metadata dependency) | ✅ |
| 3.2 | POST /api/user/billing/checkout (Checkout mode: subscription) and POST /api/user/billing/portal | ✅ |
| 3.3 | PATCH /api/user/subscription/:applicationId becomes paid-aware (retire SUB_006 from the nominal path) | ✅ (a free target applies at once; a paid target switches a live paid subscription in place or returns a Checkout URL; the route answers SUB_005 when Stripe is not configured) |
| 3.4 | Admin plan change synced to the Stripe subscription (upgrade/downgrade/prorate policy) | ✅ (item swap, Stripe default proration) |
Notes: every plan, the default free one included, is backed by a Stripe subscription.
A plan with no price at all gets a 0 EUR monthly Price on first assignment (and its
Product if missing), so the free subscription can exist and metered features on it
can bill. The sync runs from the assignment paths (admin grant, default plan,
self-service) and never from setUserSubscription, which is the webhook target —
so a Stripe event cannot loop back into a Stripe call. It is best-effort: a failure
is logged and the local entitlement stays authoritative; pnpm billing:reconcile
links the missing subscriptions.
Lot 4 — Metered billing (✅)
| # | Task | Status |
|---|---|---|
| 4.1 | billing_usage_events + billing_usage_periods; idempotent report, per-period aggregate | ✅ |
| 4.2 | POST /api/consumption metered path: detect a metered feature of the active plan, enforce the hard cap (BIL_003, HTTP 403), record the event | ✅ |
| 4.3 | Push Stripe meter events (meterEvents.create, the event id as identifier); a failed push stays pending and is retried | ✅ |
| 4.4 | Tests: cap enforcement, idempotency, meter push, period reset, unlimited (-1) | ✅ (period reset exercised by the calendar-month key) |
Notes: the per-period aggregate is authoritative for the cap; Stripe only invoices at period end.
The period is the UTC calendar month (billing_period_start), not the Stripe subscription period — the cap is our concern, the invoice is Stripe's. The event insert comes first in the transaction so a retry cannot double-count; an over-cap report rolls back both the event and the increment. The push is an outbox (stripe_meter_event_identifier stays null until Stripe accepts it), drained by pnpm billing:reconcile and a background retry every five minutes. Covered by src/tests/billing-metered.integration.test.ts.
Lot 5 — Invoices & application read API (✅)
| # | Task | Status |
|---|---|---|
| 5.1 | stripe_invoices cache filled by invoice webhooks | ✅ |
| 5.2 | App-facing API (client_credentials bound to azp): read a user's subscription, usage and invoices for the calling application | ✅ |
| 5.3 | Tests: cross-application isolation, pagination | ✅ |
Notes: the cache is written by every invoice.* event before the entitlement logic runs, so a
wallet top-up receipt (no subscription) is cached too. Owner: the Stripe Customer through
billing_customers, then the userId metadata. Application: the local subscription row, then the
applicationId metadata (a row may not exist yet when the first invoice arrives). Each row keeps
last_event_at; an update applies only when the event is not older (Stripe does not deliver in
order). The application API takes the application from the client_credentials token (azp) and
scopes every query to it; it needs the billing:read machine scope, granted by a superadmin.
Wallet top-up receipts are never exposed through it. The profile's invoice history is Lot 7.1.
Lot 6 — Delegated pages (Eta) (✅)
| # | Task | Status |
|---|---|---|
| 6.1 | Three Eta pages, subscribe, invoices, billing-usage, built-in and overridable like the other auth pages | ✅ |
| 6.2 | Failure policy: a page that cannot render answers a static 500, never the SPA; access checked before any template | ✅ |
| 6.3 | applications.billing_mode (none / delegated, default none), settable by admin routes, exposed by app-config | ✅ |
Notes: the pages are server-rendered from the same data services as the application API, and every figure
is formatted in the visitor's language. The subscribe actions are POSTs issued by a small script
inside the page (the session cookie is sent; Fastify does not parse form posts, so no <form> method).
Links from Stripe are rendered only over https://.
Lot 7 — Frontend
| # | Task | Status |
|---|---|---|
| 7.1 | Profile: real invoice history (replaces the local estimate and "coming soon"), paid upgrade/downgrade via Checkout, metered usage by feature | ✅ |
| 7.2 | Admin: per-user Stripe subscription status and cancel/resume at period end | ✅ |
Notes. Before the UI, a review of the webhook path found that a superseded subscription
(a free one still active after a paid checkout) could rewrite the entitlement of the paid one, through
its update, deletion and invoices. The fix is in the webhook service: an event is applied only when it
concerns the subscription linked to the (user, application) row, or when no link exists yet. A checkout
completion links the new subscription first and cancels the free one afterwards. Covered by
src/tests/billing-subscription-changes.integration.test.ts, whose guard tests fail when the guard is
disabled (checked by mutation). The profile's billing card now shows the real invoices and the usage of
the current period; the estimate and its placeholder are removed. Frontend has no component-test
tooling: the panel is verified by vue-tsc, the production build, and the server-side tests of the
data it shows.
Lot 8 — Hardening (✅)
| # | Task | Status |
|---|---|---|
| 8.1 | Reconciliation (DB ↔ Stripe) and operator tooling | ✅ |
| 8.2 | Security review (secrets, ApiError.details, webhook fail-closed, multi-tenant isolation, idempotency) | ✅ |
| 8.3 | Coverage ≥ 80 % on routes/** + services/**; docs sync (guide, API reference, specifications, .env.example) | 🟡 (billing modules measured above 80 %, see the change log; the repository-wide threshold is not met) |
7. Open questions
| ID | Question | Blocks |
|---|---|---|
| D11 | Org billing. Wallets and billing customers already carry a polymorphic owner_type (user/org), but this programme is user-only (D8). Open: who tops up / pays for an organization, which owner a token with org_id charges, and how a plan attaches to an org membership. Decide before exposing billing to organizations; until then the org wallet/billing paths stay unbuilt. | Lots 3, 4, 5 |
| D12 | Resolved (2026-10-27). Stripe Customers are created eagerly (best-effort hook on user creation, never blocking registration, with pnpm billing:reconcile as backfill) and every plan, the default free one included, is backed by a Stripe subscription (see Lots 2.6 and 3). | — |
8. Risks
- auth-service on the payment critical path: Stripe meters and subscriptions must tolerate retries; every mutation carries an idempotency key.
- Two money models (wallet EUR vs Stripe subscription): keep them visibly separate in the UI and in the ledger; never debit the wallet for a subscription or bill Stripe for wallet token usage.
- Meter lag: our
billing_usage_periodsaggregate is authoritative for the hard cap; Stripe's summaries are only for invoicing, so a cap decision never waits on a Stripe round-trip. - Delegated pages: fail-closed, and never render card fields (Stripe handles payment, D4).
9. Change log
| Date | Change |
|---|---|
| 2026-10-26 | Plan created. Decisions D1–D9 recorded (wallet vs subscription mechanics, env-only token, prices as the source of truth, Stripe-hosted payment/portal pages, hard metered cap billed at N+1, reuse of /api/consumption, fail-closed templates, user-only owners, auth-service owns the Stripe objects). Frozen data model captured in §3. D11 (org billing) left open. Lot 0 delivered: BIL_ error family, shared assertOwnerExists, billing_customers inlined in the 0000 baseline, src/services/billing/ module with the lazy Stripe-customer service and its tests. |
| 2026-10-26 | Lot 1 delivered: webhook handlers moved to src/services/billing/webhooks.ts and routed through setUserSubscription; user_subscriptions gained the Stripe columns (stripe_subscription_id unique, status, period, cancel flags); the entitlement now follows the Stripe subscription status, with price→product plan resolution, by-subscription-id reconciliation, inactive mirror clearing, and invoice.paid/payment_failed (dunning) handling. Deviations: checkout.session.completed (subscription mode) and customer.updated are not handled (redundant / nothing to persist), invoice.finalized deferred to Lot 5. Covered by src/tests/billing-webhook.integration.test.ts. Downstream check: the gine test project needs no adaptation (it uses only OIDC endpoints and the read-only wallet API, never POST /api/consumption nor the subscription endpoints). |
| 2026-10-26 | Lot 2 (2.1–2.3) delivered: subscription_plan_prices extended (billing_type/feature_key/unit/usage_limit/stripe_meter_id, checks, unique (plan_id, feature_key)); new src/services/billing/catalog.ts syncs Stripe Products (create/update/archive), Prices and Meters (shared per application+feature, deactivated when unused); plan price CRUD split into src/routes/admin/plan-prices.ts with shared plan-shared.ts; plan deletion archives the Product and its Prices and now also removes inactive subscriptions (the Lot 1 webhook keeps cancelled ones, which a RESTRICT FK would block). New BIL_002 (400 invalid billing configuration). Covered by src/routes/admin/plan-prices.test.ts and src/tests/billing-catalog.integration.test.ts. Deferred: 2.4 reconciliation, 2.5 admin UI. |
| 2026-10-27 | Pre-test Stripe integration audit (no code change). Shipped: catalogue Product/Price/Meter create-update-archive (2.2–2.3), subscription + invoice webhooks (Lot 1), wallet top-up/refund/dispute webhooks. Gaps confirmed: the default free plan created with an application does not create a Stripe Product; getOrCreateBillingCustomer is not wired (no Customer at all); no Stripe Subscription (Lot 3); no meter-event push (Lot 4); no invoice cache (Lot 5); deleting an application does not archive its Stripe objects. 7.stripe-integration.md rewritten to match the code: catalogue ownership, complete webhook event scope, Snapshot payload and the pinned 2026-05-27.dahlia version. D12 records the eager-customer / free-plan-subscription expectation. |
| 2026-10-27 | Stripe test-mode verification of the exact calls the code makes (same payloads, pinned 2026-05-27.dahlia): Product create/update/archive, fixed Price create/archive, Meter create/deactivate, metered Price attach, idempotent Customer create and Checkout payment session all PASS. Two feasibility probes also PASS on a fresh active Product: a $0 recurring Price + Subscription (status active) and a Meter event push (billing.meterEvents.create) — so Lots 3 and 4 are missing wiring, not Stripe capabilities. All probe objects cleaned up (Products archived, Customers deleted, Meters deactivated). |
| 2026-10-27 | Lot 2.4, 2.6 and 2.7 delivered. ensureBillingCustomerBestEffort is called from user.create.after (a Stripe failure is logged, never blocks sign-up); pnpm billing:reconcile backfills Customers, Products and Prices (create-only, never archives). An application's default free plan gets its Product at creation (best-effort); renaming an application renames its plans' Products; deleting one archives its Products/Prices and deactivates its Meters (read before the rows cascade). Covered by src/tests/billing-catalog.integration.test.ts. |
| 2026-10-27 | Lot 3 delivered (D12 resolved: eager customers, and every plan including free is backed by a Stripe subscription). New src/services/billing/subscriptions.ts: create/update/cancel a subscription, swap the item on a plan change, and a 0 EUR monthly Price auto-created for a default plan with no price (plus its Product). Sync wired at the assignment paths (admin grant, default plan, self-service) and deliberately not in setUserSubscription (webhook target), best-effort with billing:reconcile catch-up. New POST /api/user/billing/checkout (mode=subscription) and POST /api/user/billing/portal. PATCH /api/user/subscription stays free-only; paid upgrades go through Checkout. Covered by src/tests/billing-subscriptions.integration.test.ts. |
| 2026-10-28 | Lot 8 delivered. src/services/billing/reconciliation.ts audits and repairs the billing state (Customers, Products, Prices, Meters, subscription links/state/plan, superseded and orphan subscriptions, meter outbox); pnpm billing:reconcile gained --check (read-only, exit 1 on drift), --recreate-missing, --cancel-superseded, --json and --help; the default repair never recreates a deleted subscription and never cancels one without a live replacement. A correctness fix found while testing the plan drift: resolveSubscriptionTarget kept the local plan on an existing subscription, so an in-place paid plan change (and any price-driven plan change) never followed its Stripe update event; it now resolves the plan from the Stripe price, refuses a price that maps to another application's plan, and the reconciliation uses the same rule. Security review: the webhook answers a generic 400 APP_001 on a bad signature without claiming the event, invoice links are stored only when https://, Checkout/Portal URLs are validated and Checkout carries a short-window idempotency key, and the application read API isolation is covered for subscription, usage and invoices. Docs synced (guide, API reference, specifications, environment variables, .env.example). Coverage, measured by the billing test set: services/billing 81.81 % lines and the dedicated billing routes 81.03 % (reconciliation 93 %, invoices 100 %, app-views 100 %, subscriptions 90 %, webhooks 89 %, catalog 92 %, pages 87 %, usage 84 %, app-billing 95 %, stripe-webhook 86 %). The shared /api/consumption route is 68 % because its pre-existing free-counter paths dominate it; the repository-wide pnpm test:coverage threshold remains below 80 % because of pre-existing non-billing gaps (registration/invitation flows, admin stats, roles, rate-limit store), which this lot does not own. |
| 2026-10-28 | Lot 2.5 delivered. The plan editor is split out of the application view into PlanEditorModal (name, description, default, plain features) and PlanPricesDialog (subscription prices and metered features, with the Billed by usage toggle; a metered row's unit and cap are editable, its price is not). Usage bars, financial KPIs and the plan card now read the metered price rows, not the usage entries of the features JSON, which are no longer accepted. Fixed a real defect on the way: the MRR took the first month price, which for a plan with a metered row could be a per-unit price; it now counts fixed prices only. Strict inputs (D17): stripeProductId and stripePriceId are refused, unknown fields are refused, metered features declared in features are refused. The application view drops from 1420 to about 1280 lines, still above the 1000-line ceiling of AGENTS.md (its code examples and financial tab are the next extractions; recorded as open). Known limit: prices are whole minor units per unit, so a unit cheaper than one cent cannot be priced yet (Stripe's unit_amount_decimal would lift it). Tests: four strictness cases in billing-catalog.integration.test.ts. The frontend has no component-test tooling; verified by vue-tsc, the build, the Vue template compiler, and a check of every i18n key against the language objects. |
| 2026-10-27 | Lot 7 delivered. Profile: the estimate card is replaced by BillingPanel (real invoices with status and links, paginated; metered usage per application with plan, bars against the cap); the plan picker lists every plan and sends a paid choice to Stripe (in-place change or Checkout). Admin: the user's page shows the Stripe state of each subscription and stops or resumes it at period end. Correctness fix found during the review: superseded Stripe subscriptions could rewrite the entitlement; the webhook service now applies an event only when it concerns the linked subscription (D16), and a checkout completion replaces the free subscription explicitly. New: GET /api/user/billing/{invoices,usage}, POST /api/admin/applications/:appId/users/:userId/subscription/{cancel,resume}, SUB_007, SUB_008, BIL_004; SUB_006 retired. Tests: billing-subscription-changes.integration.test.ts (13, guard checked by mutation), userSubscription updated to the Checkout contract. Frontend has no component-test tooling; verified by vue-tsc and the build. Next: Lot 2.5 (admin metered editor), Lot 8 (reconciliation and security review). |
| 2026-10-27 | Suite hygiene found while finishing Lot 7. (1) The SPA catch-all answered an unknown POST with the application's HTML (200): it now answers navigations (GET/HEAD) only, an unknown API call is a 404 (regression covered by the Okta compatibility test, which fails without the guard). (2) The spa-404-assets test wrote a stand-in frontend-dist into the checkout and deleted the whole directory after it, which removed the developer's real build and made the other test files race against it. buildServer now takes an optional frontendDist; the test uses its own temporary directory. Two assertions that depended on the build's contents were made precise (missing favicon name, the provider list). The integration suite is green both with and without a local frontend build. |
| 2026-10-27 | Lot 6 delivered. applications.billing_mode (none/delegated, default none, check constraint) settable by the admin application routes and exposed by app-config. Three built-in Eta pages (subscribe, invoices, billing-usage), overridable per application, served by src/routes/billing-pages.ts for a signed-in user of a delegated application to which they have access; a page that cannot render answers a static 500, never the SPA. Data in src/services/billing/pages.ts, reusing the invoice and usage views. The template engine gains structured page data (pageData). Built-in templates added to the packaging test. Covered by src/tests/billing-pages.integration.test.ts (8) and app-config. Baseline regenerated (+billing_mode). Next: Lot 7 (profile UI, upgrade, admin actions). |
| 2026-10-27 | Lot 5 delivered. New stripe_invoices read cache (baseline regenerated) written by every invoice.* webhook event before the entitlement logic, with owner resolution (Stripe Customer, then metadata), application resolution (subscription row, then metadata), and last-writer-wins on event.created. Wallet top-ups are created with invoice_creation (D15), verified live. New application read API GET /api/app/billing/users/:userId/{subscription,usage,invoices}, authorised by a client_credentials token with the new billing:read machine scope and scoped to the token's application (never a request parameter); invoices keyset-paginated. Tests: billing-invoices.integration.test.ts (cache rules, out-of-order events, isolation, pagination, usage against the cap) and a webhook dispatch case. Profile invoice history deferred to Lot 7.1. |
| 2026-10-27 | Lot 4 delivered. New billing_usage_events / billing_usage_periods (baseline regenerated: +26 lines) and src/services/billing/usage.ts. POST /api/consumption detects a metered feature of the user's active plan, enforces the hard cap atomically (403 BIL_003, event and increment rolled back), records the report idempotently ((applicationId, idempotencyKey)) and pushes a Stripe meter event; a failed push stays pending and is drained by billing:reconcile + a five-minute background retry. The event insert precedes the increment so a retry cannot double-count. Period is the UTC calendar month. Covered by src/tests/billing-metered.integration.test.ts. |