Skip to content

Commit ba404d3

Browse files
PAYG bundle: server-authoritative price (inline amount_off coupon) + Stirling-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>
1 parent a380a82 commit ba404d3

19 files changed

Lines changed: 161 additions & 37 deletions

File tree

app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package stirling.software.saas.payg.api;
22

3+
import java.math.BigDecimal;
34
import java.time.LocalDateTime;
45
import java.time.format.DateTimeFormatter;
56
import java.util.ArrayList;
@@ -206,6 +207,12 @@ public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
206207
// Prepaid while pools still have units to draw; once exhausted the meter is live again.
207208
String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG;
208209

210+
// Per-credit rate for the bundle calculator — the bundle:processor price, NOT the metered
211+
// per-doc rate. Resolved in the team's currency (USD fallback), null when the price is
212+
// unsynced.
213+
BigDecimal bundleRatePerCreditMinor =
214+
billingService.resolveBundleRatePerCreditMinor(billing.currency());
215+
209216
WalletSnapshotResponse body =
210217
new WalletSnapshotResponse(
211218
teamId,
@@ -234,7 +241,8 @@ public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
234241
prepaidRemaining,
235242
prepaidTotal,
236243
prepaidExpiresAt,
237-
billingMode);
244+
billingMode,
245+
bundleRatePerCreditMinor);
238246
return ResponseEntity.ok(body);
239247
}
240248

@@ -512,6 +520,7 @@ private WalletSnapshotResponse emptySnapshot() {
512520
0L,
513521
0L,
514522
null,
515-
BILLING_MODE_PAYG);
523+
BILLING_MODE_PAYG,
524+
null);
516525
}
517526
}

app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
5656
* callers.
5757
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
58+
* @param bundleRatePerCreditMinor per-credit rate of the prepaid-bundle Stripe Price (lookup key
59+
* {@code bundle:processor}) in minor units of {@code currency} (may be fractional); {@code
60+
* null} when unresolved. The in-app bundle calculator multiplies its pool by this so its
61+
* estimate matches the checkout edge fn's charge. Distinct from {@code pricePerDocMinor} (the
62+
* metered per-document rate) — the two must not be conflated.
5863
*/
5964
public record WalletSnapshotResponse(
6065
Long teamId,
@@ -83,7 +88,8 @@ public record WalletSnapshotResponse(
8388
long prepaidUnitsRemaining,
8489
long prepaidUnitsTotal,
8590
String prepaidExpiresAt,
86-
String billingMode) {
91+
String billingMode,
92+
BigDecimal bundleRatePerCreditMinor) {
8793

8894
// Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter,
8995
// outside the spend cap):

app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ public class TeamBillingService {
6868
*/
6969
private static final String PAYG_LOOKUP_KEY = "plan:processor";
7070

71+
/**
72+
* Stripe Price {@code lookup_key} for the prepaid-bundle price — the per-credit rate the bundle
73+
* calculator prices its pool at. A DIFFERENT price from {@link #PAYG_LOOKUP_KEY} (the metered
74+
* per-document rate); the two must not be conflated, or the in-app estimate diverges from the
75+
* amount the checkout edge fn actually charges (which bills against this same price).
76+
*/
77+
private static final String BUNDLE_LOOKUP_KEY = "bundle:processor";
78+
7179
private final PaygTeamExtensionsRepository extensionsRepository;
7280
private final WalletPolicyRepository walletPolicyRepository;
7381
private final PricingPolicyService pricingPolicyService;
@@ -254,6 +262,23 @@ public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
254262
.longValue());
255263
}
256264

265+
/**
266+
* Per-credit rate of the prepaid-bundle Stripe Price (lookup key {@code bundle:processor}) in
267+
* {@code currency} (USD fallback) — the rate the in-app bundle calculator multiplies its pool
268+
* by, so its estimate matches the amount the checkout edge fn charges (which bills the pool
269+
* against this same price). Distinct from the metered {@code perDocMinor}; a bundle credit is
270+
* one size-scaled run, priced per {@code unit_amount} of the bundle price. {@code null} when
271+
* the rate can't be resolved (stripe schema absent, price unsynced) — the calculator then hides
272+
* the figure and defers to the server total.
273+
*/
274+
public BigDecimal resolveBundleRatePerCreditMinor(String currency) {
275+
return subscriptionDao
276+
.findRateByLookupKey(
277+
BUNDLE_LOOKUP_KEY, currency != null ? currency : DISPLAY_CURRENCY)
278+
.map(StripeSubscriptionDao.PriceRate::perDocMinor)
279+
.orElse(null);
280+
}
281+
257282
/**
258283
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
259284
* used when there's no Stripe subscription period to anchor on.

app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ public class PricingPolicy implements Serializable {
123123
@Column(name = "bundle_stripe_price_id", length = 128)
124124
private String bundleStripePriceId;
125125

126-
/** Stripe coupon id applying the 12-for-10 prepaid discount. Null = bundles not offered. */
127-
@Column(name = "bundle_coupon_id", length = 128)
128-
private String bundleCouponId;
126+
// No bundle_coupon_id field: the 12-for-10 discount is minted per-quote as an inline amount_off
127+
// coupon by the create-payg-bundle-quote edge fn (computed from the bundle Price), so the
128+
// pre-made percent coupon this policy used to carry is no longer consulted by anything. The
129+
// column still exists (payg_get_bundle_pricing returns it) and is dropped in a later cleanup;
130+
// ddl-auto=update never drops columns, so removing the mapping here is safe.
129131

130132
/**
131133
* Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx.

app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ private static WalletSnapshotResponse sample() {
4343
/* prepaidUnitsRemaining= */ 40_000L,
4444
/* prepaidUnitsTotal= */ 120_000L,
4545
/* prepaidExpiresAt= */ "2027-06-01",
46-
/* billingMode= */ "prepaid");
46+
/* billingMode= */ "prepaid",
47+
/* bundleRatePerCreditMinor= */ new BigDecimal("1"));
4748
}
4849

4950
@Test
@@ -123,10 +124,12 @@ void nullableFields() {
123124
0L,
124125
0L,
125126
null,
126-
"payg");
127+
"payg",
128+
null);
127129

128130
assertThat(free.billableLimit()).isNull();
129131
assertThat(free.pricePerDocMinor()).isNull();
132+
assertThat(free.bundleRatePerCreditMinor()).isNull();
130133
assertThat(free.currency()).isNull();
131134
assertThat(free.estimatedBillMinor()).isNull();
132135
assertThat(free.capUsd()).isNull();

frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
196196
figure={snap.remaining.toLocaleString()}
197197
capSuffix={t(
198198
"payg.prepaid.meter.capSuffix",
199-
"of {{total}} prepaid PDFs",
199+
"of {{total}} prepaid credits",
200200
{
201201
total: snap.total.toLocaleString(),
202202
},

frontend/editor/src/cloud/hooks/useWallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
128128
prev.freeAllowance !== next.freeAllowance ||
129129
prev.freeRemaining !== next.freeRemaining ||
130130
prev.pricePerDocMinor !== next.pricePerDocMinor ||
131+
prev.bundleRatePerCreditMinor !== next.bundleRatePerCreditMinor ||
131132
prev.currency !== next.currency ||
132133
prev.estimatedBillMinor !== next.estimatedBillMinor ||
133134
prev.capUsd !== next.capUsd ||

frontend/editor/src/portal/billing/stripe.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export interface BundleQuoteInput {
117117
provisionedMonthlyVolume: number;
118118
/** Size-folded run-credits = the Stripe line quantity when this quote is paid. */
119119
poolCredits: number;
120-
/** Discounted total in minor units; null when the per-run rate is unknown. */
120+
/**
121+
* Client-estimated discounted total in minor units, persisted for the pre-mint display only; null
122+
* when the per-run rate is unknown. NOT authoritative: once the Stripe quote is minted,
123+
* create-payg-bundle-quote overwrites the row's price_minor with the server-derived total
124+
* (Price x qty - amount_off), and the Stripe quote/invoice amount is server-derived regardless.
125+
*/
121126
priceMinor: number | null;
122127
currency: string;
123128
/** Affirmative consent to the prepaid→metered auto-transition (ARL/EULA §7.2). */

frontend/editor/src/portal/components/billing/ActivationChoiceModal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@ function DoorCard({
5050
* before any card is entered. Two door-cards, matching the demo —
5151
*
5252
* - Pay as you go → the metered subscription checkout (spend limit + card).
53-
* - Prepay a year → the discounted bundle (calculator + one-time payment); the
54-
* backend silently stands up the metered subscription off the saved card so
55-
* metering resumes when the pool empties, so no spend-limit step is needed.
53+
* - Prepay a year → the discounted bundle (calculator + one-time payment); no
54+
* spend-limit step, since the buyer commits to a fixed pool up front.
5655
*
5756
* Same per-PDF rate on both paths — prepay just front-loads two free months.
57+
*
58+
* Note: auto-standing-up the metered subscription off the saved card so metering
59+
* resumes once a prepaid pool empties is a known follow-up, NOT yet wired — a
60+
* prepay-only team isn't metered past its pool today.
5861
*/
5962
export function ActivationChoiceModal({
6063
open,

frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ function pipelineIdFor(mult: number): string {
100100
);
101101
}
102102

103+
/**
104+
* Reuse key for the minted Stripe quote. Includes the sizing MULTIPLIER ids alongside the pool + PO,
105+
* not just the pool: different posture/size/pipeline combos can yield the same poolCredits (identical
106+
* Stripe amount), and keying on the pool alone would skip the re-mint on such an edit and leave the
107+
* persisted quote row with stale sizing fields. Keying on the ids re-mints (and re-persists) whenever
108+
* the buyer actually changes the config.
109+
*/
110+
function buildStripeQuoteSig(
111+
poolCredits: number,
112+
postureId: string,
113+
sizeId: string,
114+
pipelineId: string,
115+
poNumber: string,
116+
): string {
117+
return `${poolCredits}|${postureId}|${sizeId}|${pipelineId}|${poNumber.trim()}`;
118+
}
119+
103120
/**
104121
* Pre-quote calculator progress, persisted per team so closing the modal / reloading doesn't lose the
105122
* 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({
189206
const { t } = useTranslation();
190207
const teamId = wallet.teamId;
191208
const currency = wallet.currency ?? "usd";
192-
const pricePerDocMinor = wallet.pricePerDocMinor;
209+
// The pool is priced per size-scaled RUN at the prepaid-bundle rate (bundle:processor), NOT the
210+
// metered per-document rate — so the estimate matches the amount the checkout edge fn charges.
211+
const ratePerRunMinor = wallet.bundleRatePerCreditMinor;
193212

194213
const [phase, setPhase] = useState<Phase>("calc");
195214
const [users, setUsers] = useState(DEFAULT_USERS);
@@ -218,10 +237,12 @@ export function BundleCheckoutModal({
218237
const [stripeQuoteSig, setStripeQuoteSig] = useState<string | null>(null);
219238
// The invoice generated when the quote is accepted (awaiting payment); null when simulated.
220239
const [invoice, setInvoice] = useState<BundleInvoice | null>(null);
221-
// On resume, the total the quote was persisted at (server value), frozen so the receipt shows what
222-
// the buyer actually quoted rather than a figure recomputed from a since-changed rate. Paired with
223-
// the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop back to
224-
// the live estimate, since editing re-mints and re-persists anyway.
240+
// On resume, the total the quote was persisted at, frozen so the receipt shows what the buyer
241+
// actually quoted rather than a figure recomputed from a since-changed rate. Once the Stripe quote
242+
// has been minted this is the server-derived total (create-payg-bundle-quote overwrites price_minor
243+
// with Price x qty - amount_off); before that it's the client estimate persisted at upsert. Paired
244+
// with the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop
245+
// back to the live estimate, since editing re-mints and re-persists anyway.
225246
const [persistedPriceMinor, setPersistedPriceMinor] = useState<number | null>(
226247
null,
227248
);
@@ -302,7 +323,13 @@ export function BundleCheckoutModal({
302323
});
303324
// Match the reuse signature so resuming doesn't immediately re-mint the Stripe quote.
304325
setStripeQuoteSig(
305-
`${latest.poolCredits}|${(saved?.poNumber ?? "").trim()}`,
326+
buildStripeQuoteSig(
327+
latest.poolCredits,
328+
postureIdFor(latest.posturePolicies),
329+
sizeIdFor(latest.sizeMult),
330+
pipelineIdFor(latest.pipelineMult),
331+
saved?.poNumber ?? "",
332+
),
306333
);
307334
}
308335
// Already accepted (an invoice exists) → resume straight to the payment step rather than the
@@ -374,9 +401,9 @@ export function BundleCheckoutModal({
374401
posturePolicies: policiesFor(postureId),
375402
sizeMult: sizeMultFor(sizeId),
376403
pipelineMult: pipelineMultFor(pipelineId),
377-
ratePerRunMinor: pricePerDocMinor,
404+
ratePerRunMinor,
378405
}),
379-
[users, postureId, sizeId, pipelineId, pricePerDocMinor],
406+
[users, postureId, sizeId, pipelineId, ratePerRunMinor],
380407
);
381408

382409
// The receipt shows the persisted (server) total on resume so it matches the quote the buyer
@@ -459,7 +486,13 @@ export function BundleCheckoutModal({
459486
stripeQuote: BundleStripeQuote;
460487
} | null> {
461488
if (teamId == null) return null;
462-
const sig = `${quote.poolCredits}|${poNumber.trim()}`;
489+
const sig = buildStripeQuoteSig(
490+
quote.poolCredits,
491+
postureId,
492+
sizeId,
493+
pipelineId,
494+
poNumber,
495+
);
463496
if (stripeQuote && stripeQuoteSig === sig && quoteId != null) {
464497
return { quoteId, stripeQuote }; // unchanged since last mint — reuse, no new quote
465498
}

0 commit comments

Comments
 (0)