PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)

## 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]>
This commit is contained in:
ConnorYoh
2026-06-11 15:56:01 +01:00
committed by GitHub
co-authored by Reece
parent 11ab762f57
commit cf513c255b
89 changed files with 11389 additions and 1034 deletions
+11 -68
View File
@@ -18,11 +18,8 @@ import {
CreditSummary,
SubscriptionInfo,
CreditCheckResult,
ApiCredits,
} from "@app/types/credits";
import apiClient, {
setGlobalCreditUpdateCallback,
} from "@app/services/apiClient";
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
@@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [profilePictureMetadata, setProfilePictureMetadata] =
useState<ProfilePictureMetadata | null>(null);
const fetchCredits = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping credit fetch");
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching credits for user:",
currentSession.user.id,
);
// Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must
// stay local rather than trigger the global login redirect.
const response = await apiClient.get<ApiCredits>("/api/v1/credits", {
suppressErrorToast: true,
skipAuthRedirect: true,
});
const apiCredits = response.data;
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits:
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed:
apiCredits.weeklyCreditsAllocated -
apiCredits.weeklyCreditsRemaining +
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
};
setCreditSummary(credits);
setCreditBalance(credits.creditsRemaining);
const subscriptionInfo: SubscriptionInfo = {
status: "active",
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits,
};
setSubscription(subscriptionInfo);
console.debug("[Auth Debug] Credits fetched successfully:", credits);
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch credits:", error);
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
}
},
[session],
);
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
// consumers of useAuth() that still destructure creditBalance / refreshCredits
// compile cleanly; values just stay null forever and refreshCredits is a noop.
// _ underscore on the param keeps the public signature stable for callers.
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
/* legacy credit fetch removed — see comment above */
}, []);
const refreshCredits = useCallback(async () => {
await fetchCredits();
}, [fetchCredits]);
/* legacy credit refresh removed — useWallet() replaces this */
}, []);
const fetchProStatus = useCallback(
async (sessionToUse?: Session | null) => {