mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
## Summary Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing stays free forever; only **automation, AI, and API** usage is metered. Every team gets a **one-time lifetime free grant** (default 500 PDFs) before any billing; past that, a team adds a card and pays per metered document, with a self-set monthly spending cap. This branch combines and supersedes the in-flight BE (#6574) and FE (#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on `v3`), hardened into a single reviewable feature after a pre-merge dead-code/security review. ## Billing model - **Always free:** manual / JWT web-tool usage is `BYPASSED` — never metered, no matter where it's triggered. - **Billable categories:** `AUTOMATION`, `AI`, `API`. - **One-time lifetime free grant** (`pricing_policy.free_tier_units`, default 500): never resets, survives subscribing. It gates unsubscribed teams (billable API calls hard-stop with a 402 once exhausted) and decides the free-vs-paid split of every job. - **Subscribed:** paid documents (beyond the grant) are metered to a Stripe Billing Meter; an optional monthly spending cap degrades billable categories when reached. - **Dedup:** the same file pushed through several steps within a workflow window counts **once** (lineage join), so API/AI chaining on one file isn't double-charged. ## What's included **Database** — Flyway migrations `V11`→`V21` with matching Supabase twins: pricing policy + per-team sidecar (`payg_team_extensions`: subscription id, Stripe customer, free-grant counter), append-only `wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit logs (`V15`), billing category (`V16`), one-time lifetime free grant (`V19`), launch-grant seed (`V20`), drop of the unused `wallet_category_summary` view (`V21`). **Charge pipeline** — `PaygChargeInterceptor` (open/join a process, split the free grant, write the ledger DEBIT), `JobChargeService` (consume the grant under a row lock, restore it on a first-step refund, meter only the paid portion on completion), `StaleJobCloser` fallback (idempotent close → meter). **Entitlement** — `EntitlementService` (per-team cached snapshot: grant-gated for free teams, monthly-cap-gated for subscribed) + `EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` / `PAYG_LIMIT_REACHED`). **Metering** — `PaygMeterReportingService` writes a durable `payg_meter_event_log` row around every POST to the `meter-payg-units` edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries unposted events under the same idempotency key inside Stripe's 24h dedup window. **Billing facts** — `TeamBillingService` reads the synced `stripe.*` mirror (subscription window, per-document rate; the unsubscribed-team estimate resolves the rate by Price `lookup_key = plan:processor`). **Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`, `PATCH /api/v1/payg/cap`. **Frontend** — PAYG Plan page (two-card free layout + subscribed views), `useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and a shared `SpendCapControl`, customer-portal link, 402/401 interceptor toast, en-GB i18n. (Per-member usage shows each teammate's spend; the activity feed is behind a flag until polished.) **SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) — `create-checkout-session`, `create-payg-team-subscription`, `create-customer-portal-session`, `meter-payg-units`, `payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync `migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no `STRIPE_PAYG_PRICE_ID_*` env vars). ## Release prerequisites (prod) 1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration twins. 2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped** backfill — `product`, `price`, `customer`, `subscription` only (not `all`, which rate-limits). 3. Register 2 PAYG webhook endpoints (each its own signing secret): `stripe-sync` (product/price/customer/subscription `.*`) and `payg-subscription-webhook` (`customer.subscription.created`/`.deleted` drive state; `.updated` + `invoice.*` observed). Keep the legacy `stripe-webhook` only if credits/self-hosted flows still run. 4. Stripe Billing Meter: `event_name = payg_doc_units`, value key `processed_documents`. 5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET` (backend); the webhook signing secrets (edge fns). The default pricing policy must point at the PAYG Stripe Price(s); `V20` seeds `free_tier_units = 500`. ## Testing - `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests green, FE saas typecheck clean (the remaining errors are pre-existing `proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode suite + CI workflow included. ## Pre-merge review An independent dead-code/security pass came back **clean on security** (team-derived authz / no IDOR, leader-only cap mutation, no billing-category downgrade, dev/mock hooks gated to `import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open metering by design). The dead/unwired code it flagged has been removed in this branch (unenforced sub-cap control, an unused JDBC DAO + its view, dead methods). ## Follow-ups (tracked, not blocking) - **Enforce per-member sub-caps** — the control was removed because it read for display but never gated a request; the per-member usage display and `cap_units` column are retained for when enforcement is wired. - **API/AI chaining billing model + `ProcessType` enum** — confirm same-file dedup covers API chaining; define per-tool AI charging; decide whether the unused enum values stay. - **Activity feed** — hidden behind a flag until the meter-event surface is polished. --------- Co-authored-by: Reece <[email protected]>