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]>
31 lines
1.2 KiB
TypeScript
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
|
|
}
|