mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
/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]>