mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
## 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>
137 lines
5.7 KiB
TypeScript
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 */
|
|
}
|
|
},
|
|
};
|
|
}
|