mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
Six defects on the money surface, all the same shape: a second copy of a fact
that only the server actually decides.
THE ANNUAL TAB QUOTED A PRICE NOBODY IS CHARGED. The interval toggle lit up
whenever any plan carried an annualPrice, and then substituted that rate into the
total: the checkout said "You are charged $41.50/mo" and the card was charged
$49.00. There is no interval on the wire — subscribeCardRequest has no such field
— and commerce charges the monthly column. commerce sells an annual plan the only
way it can, as its own catalog row, and every live row says "monthly". So the
control is gone and the annual rate stays what it already was in the line beneath
the price: information. `$${total}` also bypassed money() and printed "$41.5".
THE PRICE RULE IS NOW A FUNCTION, IN THE ONE MONEY MODULE, WITH TESTS. chargeTotal
mirrors subscribe_card.go — price x billable seats, less the active promo, computed
on cents with the server's own half-up rounding. It also stops ignoring
promoPercent: commerce annotates a plan with it only when a live promo covers that
plan and then subtracts exactly that much, so a quote that skipped it over-stated
the charge and the receipt would have disagreed with the cart the day a promo went
on. subtotal() stays undiscounted because the seat line prints `seats x unit =`
and that multiplication has to be true on screen.
TWO CATALOGS BECAME ONE. lib/plans.ts read a second catalog in DOLLARS because
"the commerce catalog is IAM-gated and 403s logged-out". Measured: GET
/v1/billing/plans answers 200 unauthenticated on commerce-api.hanzo.ai and returns
access-control-allow-origin: https://billing.hanzo.ai. The premise was false and
the cost was two unit conventions on one screen plus two projections that each
promised in a comment to mirror the other — and had already drifted on how they
decided a tier was per-seat. One catalog, one unit, one projection.
AN EXISTING PAYER IS NO LONGER SENT INTO A CHECKOUT THAT CANNOT COMPLETE. commerce
now refuses a second concurrent paid subscription, and the portal's paid branch
tested price alone, so a paying Pro customer clicking Upgrade on Max got a card
form. Before commerce refused, that charged the new plan in full and left the old
one renewing.
A DEAD CARD IS NO LONGER OFFERED, AND THE SERVER'S OWN SENTENCE SURVIVES. The
projection dropped providerRef — the vaulted card a charge names — so a method
commerce refuses to charge sat in the chooser. Both 422s then collapsed into "your
organization has no payment processor" plus a mailto, telling a customer whose fix
was "add the card again" to email support. Only the processor 422 takes the contact
panel now; every other one shows what the server said. Same for 409, which two
different conflicts share.
SIGN OUT NOW ENDS THE SESSION. It removed five localStorage keys and returned, so
the IAM cookie survived and the next Sign in re-authenticated silently — straight
back into the account, on a shared machine, for whoever sat down next. It looked
like it worked, which is why it lasted. signOut sweeps the key PREFIX across both
storages and hands back IAM's end-session URL; forgetTokens is the local-only half
the failed-callback retry needs, which must NOT end the session.
ONE PRIVILEGE PREDICATE. A hardcoded list of three email addresses decided whether
the UI offered the mint-gated doors — a second predicate for platform privilege,
compiled into a bundle, where adding a platform admin meant a redeploy. It now asks
the fact the server's own gate asks: membership of the reserved admin org.
140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
/**
|
||
* Money, formatted ONE way.
|
||
*
|
||
* Fourteen files each carried their own `fmt` / `formatCurrency` / `fmtUSD` /
|
||
* `formatBalance`, differing only in how many fraction digits they asked for —
|
||
* which is not fourteen decisions, it is one parameter. Catalog prices read as
|
||
* whole dollars (`digits: 0`), ledger amounts to the cent.
|
||
*
|
||
* Currency is upper-cased here because commerce returns `usd` while the rest of
|
||
* the app passes `USD`; normalising at the single point of use means no caller
|
||
* has to remember which side it is on.
|
||
*/
|
||
export function money(value: number, currency = 'USD', digits: 0 | 2 = 2): string {
|
||
return new Intl.NumberFormat('en-US', {
|
||
style: 'currency',
|
||
currency: currency.toUpperCase(),
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}).format(value)
|
||
}
|
||
|
||
/**
|
||
* How many fraction digits a catalog price needs to stay true.
|
||
*
|
||
* `digits: 0` is right for a catalog of whole dollars and wrong the moment one
|
||
* price carries cents. The annual rates do: Go is 8.25 against 9, Pro 41.5
|
||
* against 49. Rounding those to the dollar put two different annual prices on
|
||
* one card — a $8 headline above its own "$8.25/mo billed annually" — because
|
||
* the headline was rounded and the sub-line was not. Same number, two
|
||
* formatters, and the customer gets to decide which one the charge will be.
|
||
*
|
||
* So the price itself decides, and every catalog figure asks the same question.
|
||
*/
|
||
export function cents(value: number): 0 | 2 {
|
||
return Number.isInteger(value) ? 0 : 2
|
||
}
|
||
|
||
/** Commerce ledger amounts (balances, transactions, top-ups) are CENTS. */
|
||
export function moneyFromCents(cents: number, currency = 'USD', digits: 0 | 2 = 2): string {
|
||
return money(cents / 100, currency, digits)
|
||
}
|
||
|
||
/**
|
||
* What a year on the annual rate saves, or null when there is nothing to save.
|
||
*
|
||
* THE ANNUAL PRICE IS A MONTHLY RATE. The catalog's `priceAnnual` is what a
|
||
* month costs when you commit to a year (Pro: 41.5 against 49), not the yearly
|
||
* total — and reading it as a total produced a sentence that could not be true:
|
||
* "$41.5/yr — save $546.5" beside a $49/mo plan, where the advertised saving
|
||
* was larger than the advertised year. The plan cards had the arithmetic right
|
||
* and the overview banner had it wrong, which is what two copies of one
|
||
* calculation always eventually means.
|
||
*/
|
||
export function annualSaving(plan: { price?: number; annualPrice?: number }): number | null {
|
||
const monthly = plan.price ?? 0
|
||
const annual = plan.annualPrice ?? 0
|
||
if (monthly <= 0 || annual <= 0) return null
|
||
const saved = (monthly - annual) * 12
|
||
return saved > 0 ? saved : null
|
||
}
|
||
|
||
/** What a plan needs to price it: the catalog columns the charge reads. */
|
||
export type Priced = {
|
||
price?: number
|
||
perSeat?: boolean
|
||
minSeats?: number
|
||
promoPercent?: number
|
||
}
|
||
|
||
/**
|
||
* How many seats the charge will actually multiply by.
|
||
*
|
||
* Only a per-seat tier multiplies; a flat plan is always ×1, exactly as commerce
|
||
* does it (`seatMult = quantity` only when `perSeat(planId)`). A quote that
|
||
* multiplied every plan by its seat count would ask a customer for five times the
|
||
* real charge on a flat plan.
|
||
*
|
||
* The floor is the catalog's own `minSeats` — commerce rejects fewer with a 400,
|
||
* so the quote clamps rather than showing a total the server will refuse.
|
||
*/
|
||
export function billableSeats(plan: Priced, requested?: number): number {
|
||
if (!plan.perSeat) return 1
|
||
const floor = Math.max(1, Math.floor(plan.minSeats ?? 1))
|
||
const seats = Math.floor(requested ?? floor)
|
||
return seats < floor ? floor : seats
|
||
}
|
||
|
||
/**
|
||
* The subtotal before any promotion: the list price times the seats billed.
|
||
*
|
||
* This is the arithmetic a seat line shows — `3 × $25/mo = $75/mo` — and it has to
|
||
* be exactly that, or the numbers on screen do not add up.
|
||
*/
|
||
export function subtotal(plan: Priced, requested?: number): number {
|
||
return (plan.price ?? 0) * billableSeats(plan, requested)
|
||
}
|
||
|
||
/**
|
||
* What the card will be charged for the first period.
|
||
*
|
||
* SERVER-AUTHORITATIVE PRICING. The customer never names a price: the checkout
|
||
* POST carries `{sourceId, planId, quantity, currency}` and no amount at all, so
|
||
* commerce recomputes the charge from its own catalog and ignores anything a
|
||
* client might have sent (`api/billing/subscribe_card.go`):
|
||
*
|
||
* charge = plan.price × (perSeat ? seats : 1), less the active promo
|
||
*
|
||
* This exists for ONE reason — to show the customer the number that is about to
|
||
* leave their card — so it must mirror that rule and nothing else. Two ways it
|
||
* used to be wrong, and both put a figure on the button that no card was charged:
|
||
*
|
||
* - it reached for `annualPrice`. That is real catalog data and worth showing,
|
||
* but there is no interval on the wire and commerce charges the MONTHLY
|
||
* column, so the screen said "You are charged $41.50/mo" over a $49.00 charge.
|
||
* - it ignored `promoPercent`. commerce annotates a plan with it only when a live
|
||
* promo covers that plan, and then subtracts exactly that much, so a quote
|
||
* that skips it over-states the charge and the receipt disagrees with the cart.
|
||
*
|
||
* The discount is computed on CENTS with the server's own rounding, because doing
|
||
* it in dollars and rounding afterwards disagrees by a cent on plenty of prices.
|
||
*
|
||
* If this ever disagrees with subscribe_card.go, the quote is a lie. That is the
|
||
* whole class of defect this function exists to prevent.
|
||
*/
|
||
export function chargeTotal(plan: Priced, requested?: number): number {
|
||
const cents = Math.round(subtotal(plan, requested) * 100)
|
||
return (cents - discountCents(cents, plan.promoPercent ?? 0)) / 100
|
||
}
|
||
|
||
/**
|
||
* The promo's cut of a subtotal, mirroring commerce's engine.DiscountCents:
|
||
* `(subtotal × percent + 50) / 100` in integer cents — round half up, never more
|
||
* than the subtotal.
|
||
*/
|
||
function discountCents(cents: number, percent: number): number {
|
||
if (cents <= 0 || percent <= 0) return 0
|
||
if (percent >= 100) return cents
|
||
return Math.min(cents, Math.floor((cents * percent + 50) / 100))
|
||
}
|