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]>
54 lines
2.0 KiB
TypeScript
54 lines
2.0 KiB
TypeScript
import { Suspense } from "react";
|
|
import { Routes, Route } from "react-router-dom";
|
|
import { AppProviders } from "@app/components/AppProviders";
|
|
import { setBaseUrl } from "@app/constants/app";
|
|
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
|
import { AppLayout } from "@app/components/AppLayout";
|
|
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
|
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
|
import Landing from "@app/routes/Landing";
|
|
import Login from "@app/routes/Login";
|
|
import Signup from "@app/routes/Signup";
|
|
import AuthCallback from "@app/routes/AuthCallback";
|
|
import ResetPassword from "@app/routes/ResetPassword";
|
|
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
|
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
|
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
|
|
|
// Import global styles
|
|
import "@app/styles/tailwind.css";
|
|
import "@app/styles/saas-theme.css";
|
|
import "@app/styles/cookieconsent.css";
|
|
import "@app/styles/index.css";
|
|
|
|
// Import file ID debugging helpers (development only)
|
|
import "@app/utils/fileIdSafety";
|
|
|
|
function handleConfigLoaded(config: AppConfig) {
|
|
if (config.baseUrl) setBaseUrl(config.baseUrl);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<Suspense fallback={<LoadingFallback />}>
|
|
<AppProviders
|
|
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
|
>
|
|
<AppLayout>
|
|
<OnboardingBootstrap />
|
|
<TrialExpiredBootstrap />
|
|
<SignupRequiredBootstrap />
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/signup" element={<Signup />} />
|
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
|
<Route path="/auth/reset" element={<ResetPassword />} />
|
|
<Route path="/*" element={<Landing />} />
|
|
</Routes>
|
|
<OnboardingTour />
|
|
</AppLayout>
|
|
</AppProviders>
|
|
</Suspense>
|
|
);
|
|
}
|