Files
billing/lib/charge.ts
zooqueenandHanzo Dev 159f05783e a card on file is a card you can pay with
/topup only ever offered a blank Square form, so a returning customer keyed
their card in again on every single top-up — while the subscribe flow beside it
had offered a saved-card chooser all along. The page now asks commerce which
cards the customer has (GET /v1/billing/methods) and opens on the one they
marked default; picking it charges by payment-method id and nothing is
re-entered. Picking "use a new card" is the path that was already there.

The chooser can never stand between a customer and paying. No session, no
cards, an unreadable list, any failure at all: `cards` stays empty and the page
is exactly the form it was. A method with no vaulted card behind it is not
listed either — commerce refuses to charge one, so offering it would be
offering a button that cannot pay.

Two doors, and only the reusable one needs a lock. A nonce is single-use, so
the tokenized path carries no idempotency key; a saved card has no such
backstop, and without a key commerce falls back to a 15-minute window over
(card, amount, currency) — under which a deliberate second $25 top-up replays
the first receipt and never charges. So the saved-card charge brings its own
key, held across a lost response and retired once the attempt has an answer.
That key is now one primitive in lib/charge.ts rather than a private copy
inside the subscribe form, since both are asking the same question.

Contract, matching the route commerce already serves (api/billing/topup.go):

    POST /v1/billing/topup
    { userId, paymentMethodId, amountCents, currency? }  X-Idempotency-Key
    -> { transactionId, balanceCents, status }

The card is named by its commerce payment-method id, NOT by a Square card id:
the server reads the vaulted card and its Square customer off the method row,
and refuses a method that is not the paying subject's own. The same userId
scopes both the list and the charge, so every card offered is a card that can
actually be charged.

And the amount now survives the click. "Add credit" on the Credit Balance tile
and in the Credits panel linked to a bare /topup/, so a customer who had
already decided on $100 landed on the page's own $25 and decided again. The
amounts live in lib/topup.ts with topupHref(), the one way to link there, and
the surfaces that know an amount pass it. A junk ?amount= falls back to the
default instead of rendering "$NaN" on the Pay button.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 04:55:54 -07:00

40 lines
1.7 KiB
TypeScript

/**
* One idempotency key per money-move ATTEMPT.
*
* Commerce guards every card charge on `X-Idempotency-Key`, and when no header
* arrives it falls back to a 15-minute window over the request facts (the card,
* the amount, the currency). That fallback is right for a lost-response retry
* and wrong for a customer: a second deliberate $25 top-up inside the window has
* the same facts, so it replays the FIRST receipt and never charges — the
* balance moves once while the customer paid twice in their own head.
*
* A key that lives across a retry gives both halves. The same attempt reuses it,
* so a dropped response charges once; a genuinely new attempt mints a fresh one,
* so the second $25 is a second $25. sessionStorage, because the attempt ends
* with the tab.
*
* `scope` names what is being paid for — the charge is keyed per (what, how
* much), never per submit and never per session.
*/
const PREFIX = 'hanzo_charge:'
/** The key for this attempt at `scope`, minted on first ask and kept until cleared. */
export function chargeKey(scope: string): string {
const held = sessionStorage.getItem(PREFIX + scope)
if (held) return held
const fresh = crypto.randomUUID()
sessionStorage.setItem(PREFIX + scope, fresh)
return fresh
}
/**
* Retire the key: the attempt reached an answer. Called after a charge settles
* AND after it is refused for a reason the customer can act on (a decline, a
* card that is gone) — retrying either is a NEW charge and must not replay.
* Never called on a lost response, which is the case the key exists for.
*/
export function clearChargeKey(scope: string): void {
sessionStorage.removeItem(PREFIX + scope)
}