Files
Stirling-PDF/frontend/editor/src/saas/hooks/walletDevPreview.ts
T
ConnorYohandReece Browne ba404d3f90 PAYG bundle: server-authoritative price (inline amount_off coupon) + #7032 review nits (#7156)
## What

Follow-up to #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 #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`. #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 #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 #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>
2026-07-28 13:05:20 +00:00

137 lines
5.7 KiB
TypeScript

/**
* saas (web) implementation of the @app/hooks/walletDevPreview seam.
*
* Houses the PAYG dev-preview side-channel that {@code useWallet} used to carry
* inline. It synthesises a wallet snapshot from {@code localStorage} when the
* hook is rendered outside the real saas app (the {@code /dev/payg-preview}
* route during local design work), where {@code AppConfigContext} is not mounted
* and no backend is available. This is the only place the banned-in-cloud reads
* ({@code import.meta.env.DEV}, {@code window.location}, {@code localStorage})
* live — cloud reaches them through {@link getWalletDevPreview}.
*
* Behaviour preserved verbatim from the pre-move saas useWallet:
* - both {@code import.meta.env.DEV} AND a {@code /dev/} path are required, so a
* production tenant whose URL happens to start with {@code /dev/} can't hit
* the fallback;
* - subscription state is read from / written to {@code localStorage} so the
* modal's "mark subscribed" action survives a reload.
*/
import type { Wallet, WalletRole } from "@app/hooks/useWallet";
import type { WalletDevPreview } from "@cloud/hooks/walletDevPreview";
export type { WalletDevPreview } from "@cloud/hooks/walletDevPreview";
const STORAGE_KEY = "stirling.payg.devSubscription";
/**
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
* shape the backend returns. Subscription state comes from localStorage so
* the modal's "mark subscribed" action survives a reload.
*/
function buildDevPreviewWallet(role: WalletRole): Wallet {
const subscribed =
typeof window !== "undefined" &&
(() => {
try {
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
} catch {
return false;
}
})();
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
return {
teamId: null,
status: subscribed ? "subscribed" : "free",
role,
billingPeriodStart: isoDay(periodStart),
billingPeriodEnd: isoDay(periodEnd),
billableUsed: 62,
billableLimit: subscribed ? 1250 : 500,
freeAllowance: 500,
// One-time grant: a free team has used 62 of 500 (438 left); the dev
// subscribed team is shown with its grant fully spent (kept across the
// subscribe — it just no longer gates them).
freeRemaining: subscribed ? 0 : 438,
// Free teams also carry a rate now — the backend resolves it from the
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
// PDFs/month") can render before subscribing. Mirror that here.
pricePerDocMinor: 2,
bundleRatePerCreditMinor: 1,
currency: "usd",
estimatedBillMinor: subscribed ? 0 : null,
capUsd: subscribed ? 25 : null,
noCap: false,
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
spendUnitsThisPeriod: 62,
// Count dimension (illustrative): input files processed vs the size-scaled
// meter units above — a few large PDFs pushed some charges past 1 unit.
docsProcessedThisPeriod: 50,
uniquePdfsThisPeriod: 48,
sizeMultiplierPdfsThisPeriod: 8,
// Illustrative prepaid bundle so the /dev/payg-preview route can design the
// prepaid-capacity card + banner (drawn ahead of the meter, outside the cap).
billingMode: "prepaid",
prepaidUnitsRemaining: 78_000,
prepaidUnitsTotal: 120_000,
prepaidExpiresAt: "2027-03-01",
categoryDocs: { api: 18, ai: 14, automation: 18 },
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
// hero panel can split AI / automation / API. Use realistic but
// tier-distinguishable mock values so the dev preview shows a
// different visual when the localStorage flip toggles subscribed.
categoryBreakdown: subscribed
? { api: 12, ai: 35, automation: 15 }
: { api: 5, ai: 40, automation: 17 },
// Members are populated in the leader view by the real backend
// (joining team_memberships); the dev preview returns an empty
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
// so empty members just hides the sub-caps card.
members: [],
// Activity feed is V1 = [], the backend ships this in Wave 2 once
// payg_meter_event_log is read-accessible from the wallet endpoint.
recent: [],
};
}
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
function isDevPreviewContext(): boolean {
// Both checks required: production builds drop the path check, so a real
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
// fallback.
if (!import.meta.env.DEV) return false;
if (typeof window === "undefined") return false;
return window.location.pathname.startsWith("/dev/");
}
/** Best-effort role read for dev preview — flips per query string ?role=member. */
function devPreviewRole(): WalletRole {
if (typeof window === "undefined") return "leader";
const url = new URL(window.location.href);
return url.searchParams.get("role") === "member" ? "member" : "leader";
}
/**
* Resolve the active dev-preview side-channel, or {@code null} when we're in a
* real build / on a real route (the common case). Both {@code import.meta.env.DEV}
* and a {@code /dev/} path must hold.
*/
export function getWalletDevPreview(): WalletDevPreview | null {
if (!isDevPreviewContext()) return null;
return {
buildWallet: buildDevPreviewWallet,
role: devPreviewRole,
markSubscribed: () => {
try {
window.localStorage.setItem(STORAGE_KEY, "subscribed");
} catch {
/* storage unavailable */
}
},
};
}