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>
This commit is contained in:
co-authored by
Reece Browne
parent
a380a82234
commit
ba404d3f90
@@ -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.
|
||||
|
||||
+5
-2
@@ -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 ||
|
||||
|
||||
@@ -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 × 1¢
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user