# Prepaid usage bundles Teams on pay‑as‑you‑go can **buy a year of PDF processing up front, at a discount** — *"12 months for the price of 10."* You pre‑buy a pool of credits; they're spent **before** any metered billing and sit **outside** the monthly spend limit; unused capacity expires after 12 months. --- ## What this PR delivers **Buy → quote → invoice → pay (Stripe‑Quotes‑native).** - A team lead sizes the pool in the calculator (persisted as a quote row), which doubles as the quote page with a **"Download quote (PDF)"** — the PDF is **Stripe's own rendered quote** (same mechanism procurement uses), not an app‑generated document. - **Finalise** turns the accepted quote into an invoice; the lead can **download the invoice** or **pay online** (Stripe hosted invoice). **Card and bank‑transfer / PO** are both supported (payment‑method fork), on net terms. - The billing page loads the in‑flight quote/invoice on open, so the CTA resumes the right step (**View quote** / **Pay invoice to complete**) and offers **Cancel purchase** (voids the invoice + quote and restarts). **Prepaid is usable on its own — no subscription required.** The entitlement gate honours a live prepaid pool in both cases: - *Unsubscribed*: once the one‑time free grant is spent, a live pool keeps the team **fully entitled** (all feature gates) rather than degraded. - *Subscribed*: a team **at/over its metered cap** but holding a live pool stays fully entitled — prepaid draws are netted out of metered spend, so the pool genuinely sits outside the cap. Only when the free grant **and** the prepaid pool are both empty do billable categories stop. **Coordinated SaaS change (ships with this — `Stirling-PDF-SaaS` `v3` branch):** the `invoice.paid` webhook credits the pool idempotently (keyed on the invoice id) and settles the quote. Metered‑subscription provisioning is best‑effort and **classified** — a permanent Stripe 4xx (the single‑use hosted‑invoice card can't be attached) is a claimed no‑op (HTTP 200, no retry) so Stripe doesn't redeliver forever; only transient errors (5xx / connection / rate‑limit) retry. A failed credit now retries rather than silently dropping a paid bundle. ### Flow 1. Lead sizes the pool and agrees to the terms → the browser sends team + capacity + consent, **never a price**. 2. A leader‑gated server function looks up the price and creates a Stripe **quote** (line quantity = capacity). 3. Lead **finalises** → the quote becomes a Stripe **invoice**; download it or pay online (card or bank transfer). 4. On `invoice.paid`, the webhook **credits the prepaid pool** (idempotent) and settles the quote. 5. Usage then draws **free grant → prepaid pool → meter**; the pool is usable with no subscription. <img width="1280" height="920" alt="01-activation-fork" src="https://github.com/user-attachments/assets/e8981dc7-809d-4fc5-bfde-71e619096b7b" /> <img width="1280" height="920" alt="02-calculator" src="https://github.com/user-attachments/assets/81f6b886-1797-4c54-ba18-97d126be78e2" /> <img width="1120" height="600" alt="03-free-plan" src="https://github.com/user-attachments/assets/c4057fde-d965-47dd-93e8-f2f0f612aa73" /> <img width="1105" height="1285" alt="04-subscribed-prepaid" src="https://github.com/user-attachments/assets/9d1d0ef1-29d0-4bd4-9c17-ac5f68f88ca8" /> --- ## In a follow‑up (not this PR) 1. **Authoritative price via an inline fixed‑amount coupon** *(in progress in a separate PR).* Replace the percentage 12‑for‑10 coupon with an edge‑function‑computed **`amount_off`** coupon: the invoice shows a concrete "−$X.00" discount line, the total is deterministic (no percentage‑rounding drift), and the persisted price becomes **server‑authoritative**. Money‑mechanism change — needs validation against the Stripe test env, so it warrants its own testable PR. 2. **Metered auto‑resume when the pool empties.** Save the paying card at invoice time (`setup_future_usage`) for card payers → real `charge_automatically`; a cardless `send_invoice` subscription for bank‑transfer / PO. This makes the "processing continues at the metered rate" promise true for everyone. 3. **Provisioning idempotency hardening** (SaaS repo). Idempotency key on subscription creation + a conditional link RPC, so a webhook redelivery or link‑RPC failure can't create duplicate or orphaned subscriptions. 4. **Repo‑wide "credits" copy** across *all* of usage & billing (this PR only makes its own additions consistent). --- ## Known edges (current state) - **Cardless teams degrade when the pool empties.** An unsubscribed bundle team that runs the pool dry hits DEGRADED (metered paused), not automatic metered continuation — because no metered subscription gets provisioned off a hosted‑invoice card. The consent copy states processing "continues at the metered rate"; that promise is intentionally **ahead of the mechanism** (follow‑up 2), and the 12‑month term is the runway to deliver it. The prepaid capacity itself stays fully usable in the meantime. - **In‑app total vs charge can differ by ≤1¢** until follow‑up 1 lands. The **shared approval document (the Stripe quote PDF) and the actual invoice are already Stripe‑authoritative**; the persisted price shown in‑app is still a front‑end estimate (percentage‑coupon rounding), so it can differ from Stripe by a rounding cent. Resume‑time drift is fixed (frozen to persisted); exact‑to‑the‑penny parity arrives with the authoritative‑price follow‑up. - **Provisioning idempotency is latent, not live.** The duplicate/orphan‑subscription window only becomes reachable once card‑linking (follow‑up 2) makes provisioning actually run; hardening is tracked as follow‑up 3. - **One job can overshoot the spend cap via a near‑empty pool.** A subscribed team that has hit its metered cap but still holds a *nearly‑exhausted* pool is let through (the pool overrides the cap gate); if a job needs more than the pool has left, the pool drains to zero and the **remainder meters**, so that single job's remainder can bill just past the "never past your spend limit" ceiling. Bounded to one job's overshoot and only at the pool's tail; the alternative — blocking the job — would strand paid‑for capacity, so this is a deliberate trade. --- ## Testing - **Java** — `EntitlementServiceTest` (18) incl. unsubscribed‑live‑pool‑stays‑FULL, subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards. - **Frontend** — `useBundleFlowState` + `Usage` render tests; portal & SaaS `tsc`; i18n audit; `lint:colors`; toml‑sort; prettier. - **SaaS webhook** (`v3`) — Deno tests for terminal‑vs‑transient provisioning classification (rate‑limit treated as retryable), credit‑error‑retries, and an end‑to‑end no‑storm assertion on the unusable‑card path. *Preview:* the checkout runs in a Supabase function in `Stirling-PDF-SaaS` (`v3`); a live V2 preview is linked in the auto‑deploy comment below. Screenshots to be refreshed — the checkout modal changed since the originals.
Frontend
All frontend commands are run from the repository root using Task:
task frontend:dev— start Vite dev server (localhost:5173)task frontend:build— production buildtask frontend:test— run teststask frontend:test:watch— run tests in watch modetask frontend:lint— run ESLint + cycle detectiontask frontend:typecheck— run TypeScript type checkingtask frontend:check— run typecheck + lint + testtask frontend:install— install npm dependencies
For desktop app development, see the Tauri section below.
Layout
frontend/ is a workspace containing one or more apps. Today it holds the
PDF editor under frontend/editor/; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — package.json, node_modules,
.storybook/, ESLint, Prettier — lives at frontend/ so every app installs
once and lints with the same config.
Environment Variables
The editor's environment variables live in committed .env files at
frontend/editor/:
.env— used by all builds (core, proprietary, and as the base for desktop/SaaS).env.desktop— additional vars loaded in desktop (Tauri) mode.env.saas— additional vars loaded in SaaS mode
These files contain non-secret defaults and are checked into Git, so most dev work needs no further setup.
To override values locally (API keys, machine-specific settings), create an uncommitted sibling editor/.env.local / editor/.env.desktop.local / editor/.env.saas.local. Vite automatically layers these on top of the committed files.
Docker Setup
For Docker deployments and configuration, see the Docker README.
Tauri
All desktop tasks are available via Task. From the root of the repo:
Dev
task desktop:dev
This ensures the JLink runtime and backend JAR exist (skipping if already built), then starts Tauri in dev mode.
Build
task desktop:build
This does a full clean rebuild of the backend JAR and JLink runtime, then builds the Tauri app for production.
Platform-specific dev builds are also available:
task desktop:build:dev # No bundling
task desktop:build:dev:mac # macOS .app bundle
task desktop:build:dev:windows # Windows NSIS installer
task desktop:build:dev:linux # Linux AppImage
JLink Tasks
You can also run JLink steps individually:
task desktop:jlink # Build JAR + create JLink runtime
task desktop:jlink:jar # Build backend JAR only
task desktop:jlink:runtime # Create JLink custom JRE only
task desktop:jlink:clean # Remove JLink artifacts
Clean
task desktop:clean
Removes all desktop build artifacts including JLink runtime, bundled JARs, Cargo build, and dist/build directories.