Files
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

31 lines
1.2 KiB
TypeScript

/**
* The top-up page: what it offers, and the one way to link to it.
*
* The amounts live here rather than inside the page because the surfaces that
* send a customer there offer the same choice — the Credit Balance tile and the
* Credits panel are where "add credit" is actually clicked. Each of those used
* to link to a bare `/topup/`, so a customer who had already picked $100 landed
* on the page's own $25 and picked again. The amount the click knew travels
* with it.
*/
import { BASE_PATH } from './config'
/** Standing top-up amounts, in USD cents. The page also takes a custom amount. */
export const TOPUP_AMOUNTS = [1000, 2500, 5000, 10000]
/** What the page charges when the link named no amount. */
export const TOPUP_DEFAULT = 2500
/**
* Link to the top-up page, carrying the amount when the click knew one.
*
* BASE_PATH is applied here so no caller has to remember that a raw `<a href>`
* carries it — that is exactly the kind of detail that gets dropped in one
* surface out of three.
*/
export function topupHref(amountCents?: number): string {
const path = `${BASE_PATH}/topup/`
return amountCents && amountCents > 0 ? `${path}?amount=${Math.round(amountCents)}` : path
}