PAYG prepaid usage bundles - #7032
Conversation
Foundation for prepaid, expiring unit pools consumed ahead of the meter. - V39 migration: payg_prepaid_bundle (team_id, units_total, units_remaining, purchased_at, expires_at, stripe_ref) + partial indexes (FIFO/expiry draw; unique stripe_ref for idempotent credit). Adds payg_shadow_charge. bundle_units_consumed. Additive + idempotent. - PrepaidBundle entity: capacity + term + Stripe link only; status derived (isInTerm/isDrawable), money/currency live in Stripe, unit-cost from the team policy at charge time. - PrepaidBundleRepository: FIFO pessimistic-locked draw query (mirrors the free-grant lock), in-term read for the wallet snapshot, findByStripeRef. - Shadow charge gains bundle_units_consumed (mirrors free_units_consumed) with a columnDefinition default so ddl-auto can add it to the populated table. No behaviour change yet — the draw/purchase/UI wiring lands in later commits.
- PrepaidBundleService.draw: spend prepaid pools FIFO by soonest expiry under a pessimistic pool lock (same discipline as the free-grant deduction), skipping expired pools (lazy expiry). restore: return units to in-term pools on refund, capped at capacity, best-effort. - JobChargeService: draw the bundle between consumeFreeGrant and the metered remainder in both openProcess and chargeStandalone. Record bundle_units_consumed on the shadow row; net bundle units out of the ledger DEBIT amount (so the cap + Stripe meter only see the metered remainder) while keeping doc_count full (the PDF is still counted once by usage analytics). Meter reports units − free − bundle; the first-step-failure refund mirrors the reduced debit and restores the prepaid units too. - Test: bundle draw nets units out of the ledger + records the split; existing 420 payg tests unaffected (draw defaults to 0 with no bundle).
- PrepaidBundleService.summarize: aggregate a team's in-term pools (Σ remaining,
Σ total, soonest expiry) for the wallet snapshot.
- WalletSnapshotResponse + PaygWalletController: add prepaidUnitsRemaining,
prepaidUnitsTotal, prepaidExpiresAt, billingMode ("prepaid" while units remain,
else "payg"). Prepaid is a separate dimension from the metered spend/cap above.
- FE Wallet type + useWallet memo comparator + fixtures/dev-preview/modal-test
updated with the new fields (dev preview carries an illustrative prepaid bundle
so slice 3 can design the capacity card).
Backend payg suite + FE typecheck/test/lint/format all green.
Server-prices a prepaid-capacity request and records a short-lived, leader-authorized purchase ticket the Stripe checkout edge fn acts on (mirrors procurement: Java authorizes + records intent, the edge fn owns Stripe, the pool lands via an RPC — Java never touches the Stripe SDK). - V40: payg_bundle_quote ticket table + payg_credit_bundle(...) RPC that opens one pool, applies the 12-month term, idempotent on stripe_ref. - PrepaidPurchaseService prices units x rate x 10/12 (12-for-10), null-safe when the rate has not synced; PaygBundleController POST /payg/bundle/quote is leader-only (401 unauth / 403 member), team from the principal. - Stripe stays the source of truth for purchased quantity + amount; the edge fns + Supabase twin are specced for the SaaS repo.
Adds the buyer-facing prepaid experience on the wallet the earlier slices surface (billingMode + prepaid fields), across the editor cloud plan page and the admin portal. - Shared billing helpers (@app/billing): bundleCapacityUnits / bundleListMinor / bundlePriceMinor mirror the backend quote (units x rate x 10/12); size folds into the units so a flat per-unit price reproduces the marketing calculator's size-weighted total. Unit-tested for FE<->BE parity. - useWallet.quoteBundle(units) -> POST /api/v1/payg/bundle/quote; billing seam gains createBundleCheckoutSession (saas + desktop call create-payg-bundle- checkout, one-time Stripe session). - Editor Plan (leader): BundleCheckoutModal calculator (volume x posture x size -> capacity + discounted price), prepaid capacity meter, low-balance / expiry banner, "Prepaid year" chip, buy / top-up CTAs. Members see display only. - Portal SubscribedPlanView: prepaid capacity card + fixture + story. - en-US payg.prepaid.* / portal.billing.prepaid.* copy.
📦 Tauri Desktop Builds Ready!The desktop applications have been built and are ready for testing. Download Artifacts:🐧 Linux x64: Download Stirling-PDF-linux-x86_64 (.deb, .rpm, .AppImage) - 261.9 MB Built from commit e626da4 |
Follow-up requirement: prepay → PAYG transition consent (EULA §7.2 / California ARL)Flagged during the enterprise legal-bundle review (PR #7021). The EULA prepaid-capacity terms (§7.2) require two things at the self-serve prepay purchase step, which lives in this PR's flow — not the enterprise procurement flow:
Also log the consent per the rendering spec §4: EULA version + timestamp + sized capacity + price + term dates. Source: |
…ULA §7.2)
Affirmative consent, before payment, to the automatic transition to metered PAYG
when the prepaid term ends — required by California ARL (B&P §17602) / EULA §7.2.
- V41: consented_at + eula_version + price_minor on payg_bundle_quote (proof of
what was agreed). Additive + idempotent.
- PaygBundleController /quote takes { consented, eulaVersion }; PrepaidPurchase
service refuses a quote without consent and stamps the proof on the ticket.
- BundleCheckoutModal gates Continue on an un-pre-checked consent box disclosing
the metered rate + cancellation path; useWallet.quoteBundle forwards the EULA
version. Copy is placeholder pending legal.
Pairs with the SaaS-repo edge fn (create-payg-bundle-checkout), which refuses a
ticket without consent as defence in depth.
Purchase money-path + ARL consent landed on the SaaS repo (
|
Store the one-time bundle Price id + coupon id per pricing policy (same home as the metered price ids) instead of an edge-fn env var / lookup key — ops point a policy at its Stripe objects with a SQL UPDATE, no redeploy. - V42: pricing_policy.bundle_stripe_price_id + bundle_coupon_id (nullable; additive/idempotent). NULL = bundles not offered for that policy. - PricingPolicy entity gains the two fields. The create-payg-bundle-checkout edge fn (SaaS repo) now reads them via the payg_get_bundle_checkout_context RPC.
PrepaidBundleRepository + PrepaidBundleQuoteRepository live in payg.bundle, but SaasJpaConfig's @EnableJpaRepositories scanned only the leaf payg.repository, so they never registered → "No qualifying bean of type PrepaidBundleRepository" at startup (the @EntityScan is recursive over payg, so entities were fine; only the repo scan missed it). Mockito unit tests didn't catch it — this is the first real Spring boot with the bundle code. Add payg.bundle to the repository scan + the SaasJpaConfigScanTest guard list.
Puts the prepaid-bundle UI on the portal Processor usage/billing page (the editor Plan
page is being superseded), matching the stirling-unified demo:
- SubscribedPlanView: PrepaidCapacityCard gains a buy face — a 'Get 12 months for the
price of 10' / 'Review offer' nudge when no bundle is held, and a 'Top up' action when
one is. Opens BundleCheckoutModal (leader-only).
- FreePlanView: 'Switch on the Processor' now forks via ActivationChoiceModal — 'Pay as
you go' (existing metered checkout) vs 'Prepay a year · 2 months free' (bundle modal,
no spend-cap step; the backend silently stands up the metered sub off the saved card).
- Direct checkout (no quote ticket): BundleCheckoutModal + @portal/billing/stripe
createBundleCheckoutSession send { units, consented, eulaVersion } to
create-payg-bundle-checkout; removed @portal/api/billing quoteBundle. stripe.test.ts
guards the contract (units/consent, never quote_id).
- editor Payg.tsx: reworded/relocated the prepay CTA to the demo copy (interim; that page
is being dropped on a parallel branch).
- i18n: portal.billing.{prepaid,activation}.* (en-US).
Verified: typecheck (all projects), eslint, prettier, translation tests, portal vitest
(148 + 11 stripe), storybook build. Enterprise procurement quotes untouched.
… disable broken editor buy path Multi-agent review follow-ups (FE): - Extracted the duplicated CardPlaceholder into @portal/components/billing/CardPlaceholder and the loadStripeOnce singleton into @portal/billing/stripe.ts; BundleCheckoutModal + StripeCheckoutModal now import both instead of each keeping a byte-identical copy (one shared Stripe.js promise). - getStripePublishableKey coalesces to "" (Vite yields undefined for an unset env var), matching the saas/desktop seams and making the string return type honest. - BundleCheckoutModal imports Wallet from @portal/api/billing (the dir convention) not @app/billing. - Editor cloud Payg.tsx: canBuy = false — the editor prepaid buy/top-up posts the removed quote_id contract, which the quote-less create-payg-bundle-checkout edge fn rejects (400/409). Disabled so it can't fire; the dead path is torn down with the editor Plan page (tracked follow-up). Display of an existing prepaid balance still works. Verified: typecheck (all), eslint, prettier, translations, portal stripe vitest (11).
… XSS flag) The bundle edge fn is embedded-only (returns client_secret, never a hosted url — the url response was dropped in the SaaS repo), so session.redirectUrl is always null and the window.open(redirectUrl) branch was unreachable. Removing it eliminates the flagged sink and the dead code. The subscription checkout keeps its redirect branch (that path does return a Customer Portal url).
The bundle purchase flow moved to direct Stripe checkout (browser →
create-payg-bundle-checkout with team+units+consent; price/coupon resolved
server-side via payg_get_bundle_pricing; qty=units is the source of truth).
That left the original quote-ticket path fully dead. Remove it in one pass so
no half-wired quote surface lingers.
Java (app/saas):
- delete PaygBundleController (POST /api/v1/payg/bundle/quote), the
PrepaidBundleQuote entity + repository, and PrepaidPurchaseService, plus
their tests
- V40: keep only the live payg_credit_bundle RPC; drop the payg_bundle_quote
table/index/comments it used to co-define
- delete V41 (payg_bundle_quote consent column) — table is gone
- JobChargeService: tighten the cap-netting comment (bundle split only)
- SaasJpaConfigScanTest: drop the PrepaidBundleQuoteRepository mention
Frontend (editor):
- delete the editor BundleCheckoutModal + BundleCheckoutPanel (the live buy
UI is the portal direct-checkout flow)
- useWallet: remove quoteBundle + the BundleQuote re-export
- proprietary/billing: remove the BundleQuote type
- cloud/saas/desktop billing services: remove createBundleCheckoutSession +
BundleCheckoutParams
- Payg.tsx / Plan.tsx: rewire to display-only (an existing prepaid balance
still renders; the buy/top-up affordances are gone)
- prune 36 orphaned payg.prepaid.* editor i18n keys
Supabase twins (V40 split, V41 delete, pricing RPC) already pushed on the
SaaS repo v3 branch. NOTE: editing an applied V40 + removing V41 changes the
Flyway checksums, so the shared v3 dev DB needs `flyway repair` before the
next boot.
Removes the four Flyway migrations this PR added under app/saas/.../db/migration/saas/: V39__payg_prepaid_bundle, V40__payg_bundle_quote_and_credit, V42__payg_bundle_pricing_policy, V43__payg_bundle_quote_object Per Ludy87's review + #7100 (chore(saas): remove unused Flyway migration system): Flyway never runs against any live SaaS database — schema is authored by the Supabase migrations in Stirling-PDF-SaaS and applied by that repo's GitHub integration. These four were inert twins. The schema they carried already lives, verbatim, in the Supabase migrations (the authority): 20260720000000_payg_prepaid_bundle · 20260721000000_payg_bundle_quote_and_credit 20260724000000_payg_bundle_pricing_policy · 20260728000000_payg_bundle_quote_object (plus the Supabase-only public RPCs, which never had Flyway twins). Dropping them also clears the V39 collision with #7100's own V39__drop_classification_labels and the other open PRs. Nothing references the files (no test/ArchUnit rule); no runtime/data impact.
|
Done — removed all four Flyway migrations ( |
…ription A team that has paid for a prepaid bundle should be able to use it on its own merit, independent of any metered subscription. EntitlementService now, once the one-time free grant is exhausted, checks the prepaid pool: a live pool keeps the team FULLY entitled (all feature gates) rather than degraded; only when both the free grant and prepaid are dry do billable categories hard-stop. Prepaid is read lazily (only when the free grant is spent) so the common path never touches the prepaid table. Adds PrepaidBundleService.prepaidRemainingUnits as that read path.
… capacity Frontend for the quote -> invoice -> pay bundle flow. BundleCheckoutModal is now quote-native (calculator matches the persisted Stripe quote, finalise creates the invoice, download + pay online, card | bank-transfer fork) and the proforma PDF path is gone. useBundleFlowState loads the in-flight quote/invoice on the billing page so FreePlanView's CTA resumes the right step (View quote / Pay invoice) and offers Cancel purchase. PrepaidCapacityCard now renders on the free plan too when the team holds a live pool, so paid-for capacity is visible and usable without a subscription (pairs with the entitlement-gate change).
… the free grant Review follow-up. The subscribed entitlement branch gated purely on the metered cap, so a subscribed team at/over its cap was hard-stopped (402) even while holding live prepaid capacity — which is supposed to sit OUTSIDE the cap. Prepaid draws are already netted out of metered spend in JobChargeService, so a pool-backed job never counts toward the cap; the only gap was the gate rejecting the request before the pool could be drawn. Now, when the cap gate would DEGRADE but prepaidRemainingUnits > 0, the team stays fully entitled (shared fullyEntitledOnPrepaid helper, also used by the unsubscribed branch). Read lazily — only when the cap would otherwise stop the team. Tests: subscribed over-cap with a live pool stays FULL; over-cap with no pool still degrades; under-cap never consults prepaid. 18 tests.
Conflict: main's colour-token migration (--color-* -> --c-*) overlapped the new billing rules. Kept the checkout-scroll padding; dropped the dead step-progress rules (checkout-progress/steps/stepcount — removed with the modal rework, now unreferenced). Migrated the new rules' 10 now-undefined --color-* tokens to their --c-* equivalents per main's own mapping (text-1->text, 2->text-muted, 3/4/5->text-subtle, blue->primary, border/-input->border, border-light-> border-subtle, surface->surface), plus bg-muted->surface-sunken and blue-subtle->primary-subtle. Verified: portal tsc clean; EntitlementServiceTest 18/18.
…pt docstring
Review follow-ups. (1) payg.prepaid.banner.expiring is now count-based (_one/_other)
so it renders '1 day' not '1 days'; caller passes { count: days ?? 0 } (the arm only
renders when days is a valid number). (2) Correct the acceptBundleStripeQuote
docstring: acceptance creates the invoice as a DRAFT (auto_advance off); the payment
step finalizes it after stamping recipient + PO (the previous 'tags + finalizes' text
was wrong and misled review). Verified: SaaS typecheck clean; i18n audit 46/46; toml-sort clean.
The merged billing.css had one raw rgba(10,139,255,0.12) on the selected caplimit chip — flagged by the theme-lint css-colors check. Replace with color-mix(in srgb, var(--c-primary) 12%, transparent), matching the codebase's token pattern. All changed files now pass all four lint:colors modes.
…op stale comment Review integrity nits (FE-only, no mechanism change): - #3: the two prepaid capSuffix keys now say 'credits' not 'PDFs', so this PR's own additions are consistent (repo-wide usage/billing rename is a separate PR). - #2: on resume the receipt showed a total recomputed from the CURRENT rate, which drifts if the rate changed since mint. Freeze it to the persisted server total (keyed on pool size, so editing reverts to the live estimate). - #4: drop the 'or the mock continue button' clause from StripeCheckoutModal's onComplete doc (that button is gone). Verified: portal tsc, i18n audit, toml-sort, Usage + useBundleFlowState tests.
- PrepaidBundle: declare (team_id, expires_at) and unique(stripe_ref) indexes on the entity so ddl-auto/fresh schemas get the FIFO-draw and webhook-credit-idempotency guards the Supabase CLI migration already builds in prod. - PrepaidBundleRepository: drop the dead findByStripeRef read-then-check "guard"; the unique index is the real defence. - Add PrepaidBundleServiceTest (draw/restore/summarize/remaining). - BundleCheckoutModal: fall back to location.assign when window.open is popup-blocked; coerce persisted calc settings instead of a bare cast; use the named BundleQuoteBreakdown type. - stripe.ts: remove the unused mock flag + its test.
- Comments claiming the metered subscription is auto-provisioned off the saved card (ActivationChoiceModal, FreePlanView) now describe it as a known, not-yet-wired follow-up rather than asserting it works. - Corrects the price-authority narrative: the client-sent p_price_minor is a pre-mint display estimate only; create-payg-bundle-quote overwrites price_minor with the server-derived total once the Stripe quote is minted, and the Stripe line/amount is always server-derived (stripe.ts, BundleCheckoutModal). - Aligns 'prepaid PDFs' code fallbacks with the 'prepaid credits' TOML (usageMeters, PrepaidCapacityCard).
- #4 ensureStripeQuote reuse key now includes the posture/size/pipeline ids (buildStripeQuoteSig), not just poolCredits+PO, so a same-pool sizing edit re-mints and re-persists rather than leaving the quote row with stale sizing fields. - #5 SpendLimitPicker: a cleared field (maps to 0) no longer proceeds as a $0 cap — the cap step's Continue is disabled and handleContinue guards on it, treating empty as incomplete (distinct from the explicit null 'No limit').
…ename) Brings the branch up to today's main (95 commits, incl. PAYG prepaid bundles #7032) so the procurement/legal work can go up as a single PR off main. Conflicts resolved: * .taskfiles/frontend.yml — took main's OS-aware toplevel-name shell (it fixes the same Windows `basename` breakage more thoroughly than ours did). * SaasJpaConfig — union of both sides: payg.bundle (main) plus procurement.repository and legal (ours). * Procurement.css — main renamed the portal theme tokens (--color-* → --c-*) and had independently made the same dark-mode fix, so its side wins for every shared rule. Our agreement/legal/quote-builder additions are kept and re-pointed at the current tokens (--c-text/-subtle/-muted, --c-border, --c-surface-sunken/-raised, --c-bg, --c-danger, --c-warning); the required-field rules (.portal-qb__req / [data-invalid] / __error) are re-added with --c-danger. * translation.toml — kept both new sections ([portal.integrations*] from main, [portal.legal] from ours) in alphabetical order. Verified: :saas compileJava + spotlessJavaCheck, portal prettier + eslint, and 359/359 vitest across 63 files (incl. the translation audits) after installing main's new @tanstack/react-query dependency.
#7100 removed Flyway from the :saas build entirely (dependency dropped from app/saas/build.gradle, config removed from application-saas.properties, and all db/migration/saas/*.sql deleted), so V38__procurement_agreement_signature.sql and V39__legal_consent.sql in this branch were inert — and their V39 collided with V39 in other branches, as flagged in review. The schema for both tables is unaffected: the Supabase migrations on the SaaS repo's v3 branch are the authority (20260718000001_procurement_agreement_signature.sql and 20260719000000_legal_consent.sql, both already merged there), and the saas app reconciles the entity tables on boot via spring.jpa.hibernate.ddl-auto=update. Same arrangement the prepaid-bundle work (#7032) uses. No Java or resource loading referenced these files.
…this) Not part of this PR's feature work. PrepaidBundle.java landed in #7032 without spotless run over it, so :saas:spotlessJavaCheck currently fails on main and therefore on every branch cut from it. This is purely the formatter's own output (google-java-format re-wrapping three comments at the line limit) so the check passes here rather than showing a red gate for a file this PR never touched. Happy to drop this commit if it's being fixed on main separately.
…tirling-Tools#7032 review nits (Stirling-Tools#7156) ## What Follow-up to Stirling-Tools#7032. Makes the prepaid-bundle price **server-authoritative** and removes the percent-coupon rounding drift, by switching the 12-for-10 discount from a pre-made `percent_off` Stripe coupon to an **edge-function-computed inline `amount_off` coupon**. Also folds in Ethan's Stirling-Tools#7032 review nits. This is a money-mechanism change, so it was verified against the Deno tests and is ready for a V2-preview check before rollout. ## SaaS side — already on `v3` (purely additive) The edge fn + migration were pushed **directly to `v3`** (commit `4534ff1c1`), since the DB change is purely additive (a backward-compatible function replacement — no table/column/data changes): - `create-payg-bundle-quote`: retrieves the Stripe Price for the bundle, computes `subtotal = unit_amount x pool_credits` (falls back to `round(unit_amount_decimal x pool_credits)`), `discount = round(subtotal x 2 / 12)`, `total = subtotal - discount`; mints a single-use fixed-amount coupon (`amount_off`, `duration: once`, `max_redemptions: 1`, `redeem_by = valid_until`) and applies it instead of the stored percent coupon; persists `total` via `p_price_minor`. - Migration `20260803000000_payg_bundle_quote_stripe_price_minor.sql`: `payg_set_bundle_quote_stripe` gains `p_price_minor BIGINT DEFAULT NULL` → `price_minor = COALESCE(p_price_minor, price_minor)`. **Deploy choreography (important):** the migration must apply **before** the edge fn is deployed — the fn now calls the 4-arg `payg_set_bundle_quote_stripe`. Stirling-Tools#7032's own Supabase migration is already on `main`/`v3`. ## This PR (FE) - **Server-authoritative price:** `bundlePriceMinor` now computes `subtotal - round(subtotal x (granted-paid)/granted)` (round the discount, then subtract) — identical to the edge fn — so the pre-mint estimate matches the `amount_off` charged, and the persisted/frozen total, to the penny (they previously diverged by a minor unit on exact-half ties). Tie-case test added. ### Ethan's Stirling-Tools#7032 review nits - **1** — comments in `ActivationChoiceModal` / `FreePlanView` no longer assert the metered subscription is auto-provisioned off the saved card; they describe it as a known, not-yet-wired follow-up. - **2** — corrected the price-authority narrative (`stripe.ts`, `BundleCheckoutModal`): the client-sent `p_price_minor` is a pre-mint **display estimate only**; the edge fn overwrites `price_minor` with the server total once the quote is minted. **Verified** the edge fn builds the Stripe line from `bundle_price_id x pool_credits` with `amount_off` from the retrieved Price — it never uses the client price. - **4** — `ensureStripeQuote`'s reuse key now includes the posture/size/pipeline ids (`buildStripeQuoteSig`), not just pool+PO, so a same-pool sizing edit re-mints and re-persists instead of leaving stale sizing on the row. - **5** — `SpendLimitPicker`: a cleared field (maps to `0`) can no longer proceed as a `$0` cap — the cap-step Continue is disabled and `handleContinue` guards on it (empty = incomplete, distinct from the explicit `null` "No limit"). - **6** — `"prepaid PDFs"` code fallbacks aligned to the `"prepaid credits"` TOML (`usageMeters`, `PrepaidCapacityCard`). ## Testing - SaaS Deno: **25/25** (coupon `amount_off == round(subtotal*2/12)`, `p_price_minor == total` persisted, `unit_amount_decimal` fallback, exact-half tie, zero-discount path, price/coupon failure paths). - FE vitest: **50** billing/format tests pass; prettier + eslint clean; tsc clean for all changed files. - Pending: manual V2-preview check that the invoice shows a concrete `-$X.00` discount line (labelled "12 months for the price of 10") equal to the in-app total. Closes the residual half of Stirling-Tools#7032 review finding #2 — once merged/deployed, the in-app total, the persisted value, and the Stripe invoice all agree. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
…nsent, quote/agreement split (Stirling-Tools#7021) Consolidates the enterprise procurement and legal work into one PR off `main`. Supersedes Stirling-Tools#7020 (closed; every commit from it is contained here). Sits on top of PAYG prepaid bundles (Stirling-Tools#7032) and the `--color-*` → `--c-*` portal token rename. ## Why Enterprise procurement was a mock. The stage screens read from a fake state machine, the "agreement" was prose hardcoded in a component, and nothing a buyer did was recorded anywhere. To actually sell to an enterprise we need three things it didn't have: a real document they can read and sign, a record that proves they signed that exact version, and a licence that flips when they pay. ## What **The agreement is a real versioned document** - Registry at `resources/legal/manifest.json` + `legal/<id>/<version>/*.md`. Publishing a new version is a markdown file and a manifest bump, no code change. `@`-prefixed parts are generated sections. - `AgreementAssembler` builds MSA (Part A) + generated Order Form (Part B) + DPA (Part C) as one document. Only the Order Form varies per deal. - `AgreementPdfRenderer` goes through our own pipeline (commonmark → `FileToPdf`/WeasyPrint), so we dogfood it. - Immutable signature record pinning document id and version, a SHA-256 of the exact rendered markdown, the variable snapshot, typed signatory details, timestamp and IP. **Legal document pages and consent logging** - `GET /api/v1/legal/{docId}` serves any registry document; a viewer modal renders it with a draft badge. The SLA exhibit is viewable for the first time. - `legal_consent` + `POST /api/v1/legal/consent`. EULA clickwrap is recorded once: at trial start, or at the quote step only if there was no trial. **Quote and Agreement are separate steps** The quote step is a plain itemised review (figures, renewal, PO) with download and "Accept quote". Accepting advances to the agreement and does not charge Stripe. Signing the agreement is still the commitment point. **One quote number** We no longer mint our own reference. The Stripe quote number is the identifier everywhere, so the UI and the memo can't disagree. `quote_number` is nullable until Stripe assigns it at finalisation (`20260808000000`). **Payment takes the deal live** `invoice.paid` on the stripe-webhook moves the deal to live and the UI reflects it. Nothing watched for payment before, so a paid customer sat in "payment" forever. Needs `invoice.paid` enabled on the webhook endpoint in the Stripe dashboard. **Security** Any signup could self-issue a $0 enterprise licence, from three things compounding: leader-on-signup, no entitlement gate, and no ACV floor. So: `startTrial` now has a stage guard (it was replacing committed licences), the offline `.lic` is gated on entitlement, the ACV floor is enforced before the quote persists, and the air-gap check reads the quote's deployment rather than the deal's. Invitee emails are redacted in logs. Dev and Storybook were hitting real Stripe; both now route through `resolveDemoResponse`. **Removed the dead procurement island** The original stage-by-stage page survived the rebuild with no route and no consumer, so it was invisible to review but still cost a reader's time. 16 unreferenced files, 182 lines of superseded API, 53 orphaned en-US keys, and `Procurement.css` from 1665 to 968 lines. Nothing deleted had a live consumer. ## Screenshots Home, deal underway (hero card footer): <!-- home-in-procurement.png --> Quote builder, step 1: <!-- quote-builder.png --> Agreement, ready to sign: <!-- agreement-signing.png --> Payment and live: <!-- stage-payment.png / stage-live.png --> ## How to test **Storybook** covers every state without a backend: ```bash cd frontend && npm run storybook ``` Then `Portal/Procurement/*`: | Story | What to look at | | --- | --- | | `DealStatusHero` — Trial / Quote / Agreement / Payment / Live | One hero per stage: progress band, one-line status, stage CTA | | `QuoteBuilder` — Default | 4 steps. Users + volume drive the price; Governance and PDF size are multipliers; step 4 is the itemised review | | `ProcurementAgreement` — Default / Signing | Header actions, always-visible scrollbar on the paper, one-line signature row | | `ProcurementStages` — Payment / Live / License | "View & pay invoice" opens Stripe directly; licence key and `.lic` download | | `Views/Home` — Subscribed In Procurement | The hero in real page context | Note: `ProcurementAgreement` renders "Could not load the agreement" in Storybook because it fetches the document from the backend. The chrome is accurate, the paper body needs the app. **Full flow** needs SaaS running and a linked team: 1. Home → **Explore enterprise** → trial setup (deployment + seats). EULA is recorded here. 2. **Build your quote** → 4 steps → Generate. Buyer details are required first. 3. Review the itemised quote → **Accept quote**. Confirm Stripe was *not* charged. 4. Agreement → tick, fill signatory, **Sign agreement**. Check `procurement_signature` for the version and content hash. 5. **View & pay invoice** → pay in Stripe test mode → deal should move to live on the `invoice.paid` webhook. Worth reviewing specifically: the licence cannot be issued without entitlement (step 3 before payment), and `startTrial` on an already-committed deal is rejected rather than overwriting. ## Verification - `:saas compileJava` + `spotlessJavaCheck` - `task frontend:check:all` green end to end: 9 typecheck variants, eslint at zero warnings, `theme-lint`, `lint:css`, prettier, build, **1656 tests across 188 files** - 7 deno tests on the `invoice.paid` handler, covering all four shapes Stripe uses for the subscription reference ## Open, not addressed here - **The commercial model contradicts itself in three places.** The Order Form says annual-in-advance, the MSA §2.3/§3.2 implies otherwise, the quote engine computes `tcv = annualNet × termYears` flat, and Stripe only invoices one year. Needs a decision before this is customer-facing. - The 25 MB data-processing increments vs the ×1.4/×2.4 size multiplier, deferred pending Matt. - All legal text is **draft**. It renders with a draft badge and is not presented as executed; counsel's read is still a publish gate. - `{{subprocessor_url}}` / `{{eula_url}}` awaiting marketing's final links. - `frontend-a11y` is red on pre-existing portal contrast debt, deferred by decision. ## Schema notes Two migrations land on the SaaS side (`v3`), both applied by that repo's PR CI: - `20260808000000` drops the NOT NULL on `procurement_quote.quote_number`, which is required rather than cosmetic — the number now comes from Stripe at finalisation, so a draft holds NULL, and `ddl-auto` cannot drop an existing NOT NULL itself. - `20260809000000` adds `procurement_deal.last_paid_invoice_id`, nullable. Nothing here needs a migration in this repo: Flyway is not on the classpath, so the Java side only ever adds via `ddl-auto`, and Postgres migrations run ahead of the app deploy.
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).
Prepaid is usable on its own — no subscription required. The entitlement gate honours a live prepaid pool in both cases:
Only when the free grant and the prepaid pool are both empty do billable categories stop.
Coordinated SaaS change (ships with this —
Stirling-PDF-SaaSv3branch): theinvoice.paidwebhook 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
invoice.paid, the webhook credits the prepaid pool (idempotent) and settles the quote.In a follow‑up (not this PR)
amount_offcoupon: 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.setup_future_usage) for card payers → realcharge_automatically; a cardlesssend_invoicesubscription for bank‑transfer / PO. This makes the "processing continues at the metered rate" promise true for everyone.Known edges (current state)
Testing
EntitlementServiceTest(18) incl. unsubscribed‑live‑pool‑stays‑FULL, subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards.useBundleFlowState+Usagerender tests; portal & SaaStsc; i18n audit;lint:colors; toml‑sort; prettier.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.