Skip to content

PAYG prepaid usage bundles - #7032

Merged
ConnorYoh merged 29 commits into
mainfrom
payg-prepaid-bundles
Jul 24, 2026
Merged

PAYG prepaid usage bundles#7032
ConnorYoh merged 29 commits into
mainfrom
payg-prepaid-bundles

Conversation

@ConnorYoh

@ConnorYoh ConnorYoh commented Jul 14, 2026

Copy link
Copy Markdown
Member

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.
01-activation-fork 02-calculator 03-free-plan 04-subscribed-prepaid

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

  • JavaEntitlementServiceTest (18) incl. unsubscribed‑live‑pool‑stays‑FULL, subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards.
  • FrontenduseBundleFlowState + 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.

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.
@stirlingbot stirlingbot Bot added Java Pull requests that update Java code Front End Issues or pull requests related to front-end development Translation Issues or pull requests related to translation Test Testing-related issues or pull requests labels Jul 14, 2026
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

📦 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
Artifacts expire in 7 days

@ConnorYoh

Copy link
Copy Markdown
Member Author

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:

  1. Affirmative consent at purchase to the automatic transition to metered pay-as-you-go when the prepaid term ends (a checkbox or equivalent affirmative act, disclosed before payment). Required by the California Automatic Renewal Law (B&P §17602).
  2. −30-day reminder email before term end that states (a) the metered rates that will then apply and (b) the cancellation / revert-to-free path.

Also log the consent per the rendering spec §4: EULA version + timestamp + sized capacity + price + term dates.

Source: connor-legal-spec.md §4 and the EULA §7.2 draft. Not blocking #7021; belongs with the prepay purchase UI here.

…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.
@ConnorYoh

Copy link
Copy Markdown
Member Author

Purchase money-path + ARL consent landed on the SaaS repo (v3 @ 08d6af031)

Follow-up to the ARL consent requirement — the prepay purchase path is now built across both repos:

Main repo (this PR, edf688ef09) — consent captured at the quote step: payg_bundle_quote gains consented_at + eula_version + price_minor (V41); /quote requires { consented, eulaVersion }; BundleCheckoutModal gates Continue on an un-pre-checked consent box.

SaaS repo v3create-payg-bundle-checkout (one-time mode:payment, bundle Price by lookup key × units + 12-for-10 coupon, setup_future_usage; refuses expired/mismatched/un-consented tickets), payg-subscription-webhook credits payg_credit_bundle on checkout.session.completed (Stripe line qty = SoT, idempotent), + public RPC wrappers. 11 Deno tests green.

Still required before GA (not code):

  1. Stripe one-time Price bundle:processor (same unit_amount as the meter) + 12-for-10 coupon (PAYG_BUNDLE_COUPON_ID) — dashboard config. Until these exist the edge fn returns bundle_coupon_not_configured and the FE stays in mock mode.
  2. Legal to finalise the consent copy + EULA version (currently placeholder 2026-07-draft) + the reminder window.
  3. −30-day reminder email (scheduled scan → metered rate + cancel path) — still a separate follow-up, not yet built.

Heads-up unrelated to this change: v3's existing payg-subscription-webhook handler tests are red on HEAD (client.rpc vs .schema().rpc stub mismatch) — pre-dates this work; the new bundle tests pass.

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).
@ConnorYoh
ConnorYoh marked this pull request as ready for review July 16, 2026 11:35
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines ignoring generated files. enhancement New feature or request labels Jul 16, 2026
Comment thread frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx Outdated
@ConnorYoh ConnorYoh changed the title PAYG prepaid usage bundles (slices 1–3) PAYG prepaid usage bundles Jul 16, 2026
… 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).
@stirlingbot stirlingbot Bot removed the enhancement New feature or request label Jul 16, 2026
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.

Ludy87 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Heads-up: PR #7100 removes Flyway from the SaaS build entirely. This PR adds Flyway migrations V39, V40, V42, and V43; V39 conflicts with other open PRs. Please coordinate these schema changes with #7100 before merging and confirm whether they should move to the Supabase migrations instead.

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.
@ConnorYoh

ConnorYoh commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Done — removed all four Flyway migrations (V39/V40/V42/V43) from this PR in f5d66af.

…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.
@ConnorYoh
ConnorYoh enabled auto-merge July 24, 2026 10:23
@ConnorYoh
ConnorYoh added this pull request to the merge queue Jul 24, 2026
Merged via the queue into main with commit 3813ca3 Jul 24, 2026
95 of 107 checks passed
@ConnorYoh
ConnorYoh deleted the payg-prepaid-bundles branch July 24, 2026 13:24
ConnorYoh added a commit that referenced this pull request Jul 24, 2026
- 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).
ConnorYoh added a commit that referenced this pull request Jul 24, 2026
- #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').
ConnorYoh added a commit that referenced this pull request Jul 28, 2026
…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.
ConnorYoh added a commit that referenced this pull request Jul 28, 2026
#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.
ConnorYoh added a commit that referenced this pull request Jul 28, 2026
…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.
pull Bot pushed a commit to 5474312/Stirling-PDF that referenced this pull request Jul 28, 2026
…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>
pull Bot pushed a commit to jnnycn007/Stirling-PDF that referenced this pull request Aug 5, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Front End Issues or pull requests related to front-end development Java Pull requests that update Java code size:XXL This PR changes 1000+ lines ignoring generated files. Test Testing-related issues or pull requests Translation Issues or pull requests related to translation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants