mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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]>
33 lines
1.8 KiB
Properties
33 lines
1.8 KiB
Properties
# SaaS dev profile. Points at the dev Supabase project.
|
|
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
|
|
spring.config.import=optional:classpath:application-dev-local.properties
|
|
|
|
app.supabase.project-ref=qacaivhsjtftfwtgjvva
|
|
|
|
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
|
|
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
|
|
|
|
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
|
|
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
|
|
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
|
|
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
|
|
|
|
# Conservative dev pool sizing.
|
|
spring.datasource.hikari.maximum-pool-size=2
|
|
spring.datasource.hikari.minimum-idle=1
|
|
spring.datasource.hikari.idle-timeout=60000
|
|
spring.datasource.hikari.max-lifetime=1800000
|
|
spring.datasource.hikari.keepalive-time=300000
|
|
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
|
|
|
|
logging.level.stirling.software.saas=DEBUG
|
|
logging.level.org.springframework.security.oauth2.jwt=WARN
|
|
logging.level.org.springframework.security.oauth2.server.resource=WARN
|
|
|
|
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
|
|
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
|
|
# shared secret the team-invitation flow uses — no service-role key in the Java env).
|
|
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
|
|
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
|
|
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
|