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>
This commit is contained in:
ConnorYoh
2026-07-28 13:05:20 +00:00
committed by GitHub
co-authored by Reece Browne
parent a380a82234
commit ba404d3f90
19 changed files with 161 additions and 37 deletions
@@ -1,5 +1,6 @@
package stirling.software.saas.payg.api;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -206,6 +207,12 @@ public class PaygWalletController {
// Prepaid while pools still have units to draw; once exhausted the meter is live again.
String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG;
// Per-credit rate for the bundle calculator — the bundle:processor price, NOT the metered
// per-doc rate. Resolved in the team's currency (USD fallback), null when the price is
// unsynced.
BigDecimal bundleRatePerCreditMinor =
billingService.resolveBundleRatePerCreditMinor(billing.currency());
WalletSnapshotResponse body =
new WalletSnapshotResponse(
teamId,
@@ -234,7 +241,8 @@ public class PaygWalletController {
prepaidRemaining,
prepaidTotal,
prepaidExpiresAt,
billingMode);
billingMode,
bundleRatePerCreditMinor);
return ResponseEntity.ok(body);
}
@@ -512,6 +520,7 @@ public class PaygWalletController {
0L,
0L,
null,
BILLING_MODE_PAYG);
BILLING_MODE_PAYG,
null);
}
}
@@ -55,6 +55,11 @@ import java.util.List;
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
* callers.
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
* @param bundleRatePerCreditMinor per-credit rate of the prepaid-bundle Stripe Price (lookup key
* {@code bundle:processor}) in minor units of {@code currency} (may be fractional); {@code
* null} when unresolved. The in-app bundle calculator multiplies its pool by this so its
* estimate matches the checkout edge fn's charge. Distinct from {@code pricePerDocMinor} (the
* metered per-document rate) — the two must not be conflated.
*/
public record WalletSnapshotResponse(
Long teamId,
@@ -83,7 +88,8 @@ public record WalletSnapshotResponse(
long prepaidUnitsRemaining,
long prepaidUnitsTotal,
String prepaidExpiresAt,
String billingMode) {
String billingMode,
BigDecimal bundleRatePerCreditMinor) {
// Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter,
// outside the spend cap):
@@ -68,6 +68,14 @@ public class TeamBillingService {
*/
private static final String PAYG_LOOKUP_KEY = "plan:processor";
/**
* Stripe Price {@code lookup_key} for the prepaid-bundle price — the per-credit rate the bundle
* calculator prices its pool at. A DIFFERENT price from {@link #PAYG_LOOKUP_KEY} (the metered
* per-document rate); the two must not be conflated, or the in-app estimate diverges from the
* amount the checkout edge fn actually charges (which bills against this same price).
*/
private static final String BUNDLE_LOOKUP_KEY = "bundle:processor";
private final PaygTeamExtensionsRepository extensionsRepository;
private final WalletPolicyRepository walletPolicyRepository;
private final PricingPolicyService pricingPolicyService;
@@ -254,6 +262,23 @@ public class TeamBillingService {
.longValue());
}
/**
* Per-credit rate of the prepaid-bundle Stripe Price (lookup key {@code bundle:processor}) in
* {@code currency} (USD fallback) — the rate the in-app bundle calculator multiplies its pool
* by, so its estimate matches the amount the checkout edge fn charges (which bills the pool
* against this same price). Distinct from the metered {@code perDocMinor}; a bundle credit is
* one size-scaled run, priced per {@code unit_amount} of the bundle price. {@code null} when
* the rate can't be resolved (stripe schema absent, price unsynced) — the calculator then hides
* the figure and defers to the server total.
*/
public BigDecimal resolveBundleRatePerCreditMinor(String currency) {
return subscriptionDao
.findRateByLookupKey(
BUNDLE_LOOKUP_KEY, currency != null ? currency : DISPLAY_CURRENCY)
.map(StripeSubscriptionDao.PriceRate::perDocMinor)
.orElse(null);
}
/**
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
* used when there's no Stripe subscription period to anchor on.
@@ -123,9 +123,11 @@ public class PricingPolicy implements Serializable {
@Column(name = "bundle_stripe_price_id", length = 128)
private String bundleStripePriceId;
/** Stripe coupon id applying the 12-for-10 prepaid discount. Null = bundles not offered. */
@Column(name = "bundle_coupon_id", length = 128)
private String bundleCouponId;
// No bundle_coupon_id field: the 12-for-10 discount is minted per-quote as an inline amount_off
// coupon by the create-payg-bundle-quote edge fn (computed from the bundle Price), so the
// pre-made percent coupon this policy used to carry is no longer consulted by anything. The
// column still exists (payg_get_bundle_pricing returns it) and is dropped in a later cleanup;
// ddl-auto=update never drops columns, so removing the mapping here is safe.
/**
* Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx.
@@ -43,7 +43,8 @@ class WalletSnapshotResponseTest {
/* prepaidUnitsRemaining= */ 40_000L,
/* prepaidUnitsTotal= */ 120_000L,
/* prepaidExpiresAt= */ "2027-06-01",
/* billingMode= */ "prepaid");
/* billingMode= */ "prepaid",
/* bundleRatePerCreditMinor= */ new BigDecimal("1"));
}
@Test
@@ -123,10 +124,12 @@ class WalletSnapshotResponseTest {
0L,
0L,
null,
"payg");
"payg",
null);
assertThat(free.billableLimit()).isNull();
assertThat(free.pricePerDocMinor()).isNull();
assertThat(free.bundleRatePerCreditMinor()).isNull();
assertThat(free.currency()).isNull();
assertThat(free.estimatedBillMinor()).isNull();
assertThat(free.capUsd()).isNull();
@@ -196,7 +196,7 @@ export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
figure={snap.remaining.toLocaleString()}
capSuffix={t(
"payg.prepaid.meter.capSuffix",
"of {{total}} prepaid PDFs",
"of {{total}} prepaid credits",
{
total: snap.total.toLocaleString(),
},
@@ -128,6 +128,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
prev.freeAllowance !== next.freeAllowance ||
prev.freeRemaining !== next.freeRemaining ||
prev.pricePerDocMinor !== next.pricePerDocMinor ||
prev.bundleRatePerCreditMinor !== next.bundleRatePerCreditMinor ||
prev.currency !== next.currency ||
prev.estimatedBillMinor !== next.estimatedBillMinor ||
prev.capUsd !== next.capUsd ||
+6 -1
View File
@@ -117,7 +117,12 @@ export interface BundleQuoteInput {
provisionedMonthlyVolume: number;
/** Size-folded run-credits = the Stripe line quantity when this quote is paid. */
poolCredits: number;
/** Discounted total in minor units; null when the per-run rate is unknown. */
/**
* Client-estimated discounted total in minor units, persisted for the pre-mint display only; null
* when the per-run rate is unknown. NOT authoritative: once the Stripe quote is minted,
* create-payg-bundle-quote overwrites the row's price_minor with the server-derived total
* (Price x qty - amount_off), and the Stripe quote/invoice amount is server-derived regardless.
*/
priceMinor: number | null;
currency: string;
/** Affirmative consent to the prepaid→metered auto-transition (ARL/EULA §7.2). */
@@ -50,11 +50,14 @@ function DoorCard({
* before any card is entered. Two door-cards, matching the demo —
*
* - Pay as you go → the metered subscription checkout (spend limit + card).
* - Prepay a year → the discounted bundle (calculator + one-time payment); the
* backend silently stands up the metered subscription off the saved card so
* metering resumes when the pool empties, so no spend-limit step is needed.
* - Prepay a year → the discounted bundle (calculator + one-time payment); no
* spend-limit step, since the buyer commits to a fixed pool up front.
*
* Same per-PDF rate on both paths — prepay just front-loads two free months.
*
* Note: auto-standing-up the metered subscription off the saved card so metering
* resumes once a prepaid pool empties is a known follow-up, NOT yet wired — a
* prepay-only team isn't metered past its pool today.
*/
export function ActivationChoiceModal({
open,
@@ -100,6 +100,23 @@ function pipelineIdFor(mult: number): string {
);
}
/**
* Reuse key for the minted Stripe quote. Includes the sizing MULTIPLIER ids alongside the pool + PO,
* not just the pool: different posture/size/pipeline combos can yield the same poolCredits (identical
* Stripe amount), and keying on the pool alone would skip the re-mint on such an edit and leave the
* persisted quote row with stale sizing fields. Keying on the ids re-mints (and re-persists) whenever
* the buyer actually changes the config.
*/
function buildStripeQuoteSig(
poolCredits: number,
postureId: string,
sizeId: string,
pipelineId: string,
poNumber: string,
): string {
return `${poolCredits}|${postureId}|${sizeId}|${pipelineId}|${poNumber.trim()}`;
}
/**
* Pre-quote calculator progress, persisted per team so closing the modal / reloading doesn't lose the
* buyer's place. Once a real quote exists it's the source of truth (loaded server-side), so this is
@@ -189,7 +206,9 @@ export function BundleCheckoutModal({
const { t } = useTranslation();
const teamId = wallet.teamId;
const currency = wallet.currency ?? "usd";
const pricePerDocMinor = wallet.pricePerDocMinor;
// The pool is priced per size-scaled RUN at the prepaid-bundle rate (bundle:processor), NOT the
// metered per-document rate — so the estimate matches the amount the checkout edge fn charges.
const ratePerRunMinor = wallet.bundleRatePerCreditMinor;
const [phase, setPhase] = useState<Phase>("calc");
const [users, setUsers] = useState(DEFAULT_USERS);
@@ -218,10 +237,12 @@ export function BundleCheckoutModal({
const [stripeQuoteSig, setStripeQuoteSig] = useState<string | null>(null);
// The invoice generated when the quote is accepted (awaiting payment); null when simulated.
const [invoice, setInvoice] = useState<BundleInvoice | null>(null);
// On resume, the total the quote was persisted at (server value), frozen so the receipt shows what
// the buyer actually quoted rather than a figure recomputed from a since-changed rate. Paired with
// the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop back to
// the live estimate, since editing re-mints and re-persists anyway.
// On resume, the total the quote was persisted at, frozen so the receipt shows what the buyer
// actually quoted rather than a figure recomputed from a since-changed rate. Once the Stripe quote
// has been minted this is the server-derived total (create-payg-bundle-quote overwrites price_minor
// with Price x qty - amount_off); before that it's the client estimate persisted at upsert. Paired
// with the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop
// back to the live estimate, since editing re-mints and re-persists anyway.
const [persistedPriceMinor, setPersistedPriceMinor] = useState<number | null>(
null,
);
@@ -302,7 +323,13 @@ export function BundleCheckoutModal({
});
// Match the reuse signature so resuming doesn't immediately re-mint the Stripe quote.
setStripeQuoteSig(
`${latest.poolCredits}|${(saved?.poNumber ?? "").trim()}`,
buildStripeQuoteSig(
latest.poolCredits,
postureIdFor(latest.posturePolicies),
sizeIdFor(latest.sizeMult),
pipelineIdFor(latest.pipelineMult),
saved?.poNumber ?? "",
),
);
}
// Already accepted (an invoice exists) → resume straight to the payment step rather than the
@@ -374,9 +401,9 @@ export function BundleCheckoutModal({
posturePolicies: policiesFor(postureId),
sizeMult: sizeMultFor(sizeId),
pipelineMult: pipelineMultFor(pipelineId),
ratePerRunMinor: pricePerDocMinor,
ratePerRunMinor,
}),
[users, postureId, sizeId, pipelineId, pricePerDocMinor],
[users, postureId, sizeId, pipelineId, ratePerRunMinor],
);
// The receipt shows the persisted (server) total on resume so it matches the quote the buyer
@@ -459,7 +486,13 @@ export function BundleCheckoutModal({
stripeQuote: BundleStripeQuote;
} | null> {
if (teamId == null) return null;
const sig = `${quote.poolCredits}|${poNumber.trim()}`;
const sig = buildStripeQuoteSig(
quote.poolCredits,
postureId,
sizeId,
pipelineId,
poNumber,
);
if (stripeQuote && stripeQuoteSig === sig && quoteId != null) {
return { quoteId, stripeQuote }; // unchanged since last mint — reuse, no new quote
}
@@ -184,9 +184,11 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
)}
{/* Prepay reuses the bundle modal (free team → first-purchase copy, no cap
step). On completion the webhook credits the pool AND silently creates
the metered subscription off the saved card, so we poll like the payg
path to flip the wallet to subscribed. */}
step). On completion the webhook credits the pool; we still poll onSubscribed
like the payg path, but flipping the wallet to subscribed depends on the
metered-subscription auto-provisioning off the saved card, a known follow-up
that's NOT yet wired — so for a prepay-only team this poll can just time out
until then. */}
{wallet.teamId != null && (
<BundleCheckoutModal
open={step === "prepay"}
@@ -75,7 +75,7 @@ export function PrepaidCapacityCard({
figure={remaining.toLocaleString()}
capSuffix={t(
"portal.billing.prepaid.capSuffix",
"of {{total}} prepaid PDFs",
"of {{total}} prepaid credits",
{
total: total.toLocaleString(),
},
@@ -143,6 +143,8 @@ function SpendLimitPicker({
const onInput = (raw: string) => {
const cleaned = raw.replace(/[^0-9]/g, "");
setText(cleaned);
// Empty maps to 0 — the "nothing entered yet" sentinel (distinct from the explicit null "No limit").
// The parent treats 0 as incomplete and blocks Continue, so a cleared field can't become a $0 cap.
onChange(cleaned === "" ? 0 : parseInt(cleaned, 10));
};
@@ -326,6 +328,10 @@ export function StripeCheckoutModal({
// Apply the chosen ceiling, then advance to payment. Applying it up front keeps
// "you're never billed past it" true from the first processed PDF.
async function handleContinue() {
// Guard the empty-field sentinel: clearing the input maps to 0, which is neither a real cap nor the
// explicit "No limit" (null). Proceeding would set a $0 ceiling (processing immediately paused), so
// treat it as incomplete and stay put — the Continue button is disabled in this state too.
if (capUsd !== null && capUsd <= 0) return;
setCapBusy(true);
setCapError(null);
try {
@@ -357,6 +363,10 @@ export function StripeCheckoutModal({
if (dismissable) onClose();
};
// Valid to continue when a positive cap is set OR "No limit" (null) was explicitly picked. A cleared
// field maps to 0 (incomplete) — block Continue rather than let it become an accidental $0 ceiling.
const capValid = capUsd === null || capUsd > 0;
// The chosen cap, formatted for the payment-step recap (null = "No cap" was picked on step 1).
const capLabel =
capUsd != null
@@ -443,6 +453,7 @@ export function StripeCheckoutModal({
<Button
accent="premium"
loading={capBusy}
disabled={!capValid}
onClick={handleContinue}
rightSection={<span aria-hidden></span>}
>
@@ -12,6 +12,7 @@ export const freeWallet: Wallet = {
freeAllowance: 500,
freeRemaining: 380,
pricePerDocMinor: 2,
bundleRatePerCreditMinor: 1,
currency: "usd",
estimatedBillMinor: null,
capUsd: null,
@@ -43,6 +44,7 @@ export const subscribedWallet: Wallet = {
freeAllowance: 500,
freeRemaining: 0,
pricePerDocMinor: 2,
bundleRatePerCreditMinor: 1,
currency: "usd",
estimatedBillMinor: 4500,
capUsd: 1000,
@@ -54,7 +54,7 @@ describe("prepaid bundle brain", () => {
expect(q.provisionedMonthlyVolume).toBe(10000);
expect(q.poolCredits).toBe(576_000);
expect(q.listMinor).toBe(576_000); // 576k credits ×
expect(q.priceMinor).toBe(480_000); // × 10/12
expect(q.priceMinor).toBe(480_000); // 576,000 round(576000×2/12)=96,000
expect(q.savingsMinor).toBe(96_000);
expect(q.overEnterprise).toBe(false);
});
@@ -86,17 +86,26 @@ describe("prepaid bundle brain", () => {
});
it("applies the 12-for-10 discount at the per-run rate", () => {
// 120k credits × 2 minor = 240,000 list; × 10/12 = 200,000 paid.
// 120k credits × 2 minor = 240,000 list; minus round(240000×2/12)=40,000 = 200,000 paid.
expect(bundleListMinor(120_000, 2)).toBe(240_000);
expect(bundlePriceMinor(120_000, 2)).toBe(200_000);
});
it("rounds sub-cent rates to the minor unit (HALF_UP)", () => {
// 100k × 0.5 = 50,000 list; × 10/12 = 41,666.67 → 41,667.
// 100k × 0.5 = 50,000 list; minus round(50000×2/12)=round(8333.33)=8,333 = 41,667.
expect(bundleListMinor(100_000, 0.5)).toBe(50_000);
expect(bundlePriceMinor(100_000, 0.5)).toBe(41_667);
});
it("rounds the discount then subtracts, matching the edge fn on exact-half ties", () => {
// The mechanism that kills the coupon-rounding drift: round the DISCOUNT and subtract it, not
// round the discounted price. On a tie the two differ by a minor unit.
// subtotal 3 → discount round(0.5)=1 → 2 (round(3×10/12)=round(2.5)=3 would drift).
expect(bundlePriceMinor(3, 1)).toBe(2);
// subtotal 9 → discount round(1.5)=2 → 7 (round(9×10/12)=round(7.5)=8 would drift).
expect(bundlePriceMinor(9, 1)).toBe(7);
});
it("returns null money when the rate is unknown or non-positive", () => {
expect(bundleListMinor(120_000, null)).toBeNull();
expect(bundlePriceMinor(120_000, null)).toBeNull();
@@ -199,9 +199,13 @@ export function bundleListMinor(
}
/**
* Discounted price of a prepaid pool in minor units: units × rate × paid/granted.
* Mirror of the Stripe coupon. Null when the rate is unknown (the caller hides the
* figure and falls back to the server total).
* Discounted price of a prepaid pool in minor units: the list subtotal minus the
* rounded 12-for-10 discount. Computed the SAME way as the edge fn that mints the
* Stripe coupon (create-payg-bundle-quote: round the DISCOUNT, then subtract it —
* not round the discounted price), so this pre-mint estimate matches the amount_off
* Stripe charges, and the total persisted on the quote, to the penny. The two methods
* diverge by a minor unit on exact-half ties. Null when the rate is unknown (the
* caller hides the figure and falls back to the server total).
*/
export function bundlePriceMinor(
units: number,
@@ -209,11 +213,12 @@ export function bundlePriceMinor(
monthsPaid: number = PREPAID_MONTHS_PAID,
monthsGranted: number = PREPAID_MONTHS_GRANTED,
): number | null {
const rate =
ratePerUnitMinor != null && ratePerUnitMinor > 0 ? ratePerUnitMinor : null;
return rate != null
? Math.round((units * rate * monthsPaid) / monthsGranted)
: null;
const subtotal = bundleListMinor(units, ratePerUnitMinor);
if (subtotal == null) return null;
const discount = Math.round(
(subtotal * (monthsGranted - monthsPaid)) / monthsGranted,
);
return subtotal - discount;
}
/** Inputs to {@link computeBundleQuote} — team size + the finer-setting multipliers. */
@@ -50,6 +50,12 @@ export interface Wallet {
freeRemaining: number;
/** Paid per-document rate in minor units (may be fractional); null = unknown (render "unknown", never substitute). */
pricePerDocMinor: number | null;
/**
* Per-credit rate of the prepaid-bundle Stripe Price ({@code bundle:processor}) in minor units;
* null when unresolved. What the bundle calculator prices its pool at so the estimate matches the
* checkout charge — distinct from {@link pricePerDocMinor} (the metered per-doc rate).
*/
bundleRatePerCreditMinor: number | null;
/** Lower-case ISO 4217; null when unknown. */
currency: string | null;
/** Estimated charges so far this period in minor units; null when the rate is unknown. The Stripe invoice is authoritative. */
@@ -20,6 +20,7 @@ const wallet: Wallet = {
freeAllowance: 500,
freeRemaining: 0,
pricePerDocMinor: 2,
bundleRatePerCreditMinor: 1,
currency: "usd",
estimatedBillMinor: 0,
capUsd: null,
@@ -61,6 +61,7 @@ function buildDevPreviewWallet(role: WalletRole): Wallet {
// 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,