Current-state prose describing the type as Geist. Left alone: anything that records what HAPPENED — a changelog line, or the generated class name `__Geist_Mono_abc123` that billing names as the cause of a past bug. A record of a fix is not a claim about today.
27 KiB
Hanzo Billing
Overview
Hanzo billing service
Tech Stack
- TypeScript 5.9.3 · React 19 · Next 16.2 (
output: 'export', Turbopack) @hanzo/ui8.0.72 — the shared handle vocabulary (mono,tnum,paper,row,elevation-1..4)..btnand.badgedo NOT exist in it; anything that looks like one is defined locally inapp/global.css. Never below 8.0.69 — see The focus ring below.@hanzo/design0.5.1 — the colour ladder AND the two self-hosted faces. Never below 0.4.9 — same reason.@hanzo/gui8.1.1 — shipsgui-css-check, whichpostbuildruns.@hanzogui/shell8.1.5 for the cross-app chrome (TenantHeader, ORG_DOMAINS). Never 8.0.3 — that release published 966 lines of Tailwind classes with no Tailwind in its dependencies and rendered completely unstyled. 8.0.6 ships zero CSS and zero className literals; it styles itself with inline style objects and one scoped[data-hanzo-shell]sheet.qr0.5.5 — 0-dep QR encoder, one use: the crypto deposit address on/topup, drawn as an inline<svg>string. No CSS, no image request.- No Tailwind, no Radix, no shadcn, no tailwind-merge, no PostCSS config. See Styling below.
Build & Run
pnpm install && pnpm build # static export -> out/, then gui-css-check
pnpm lint # scripts/pci-scope.mjs — the SAQ-A boundary
pnpm typecheck # tsc --noEmit
pnpm lint scans BOTH the source and the built bundle, so it needs pnpm build
to have run — it refuses to pass on a scan that examined nothing.
Structure
billing/
Dockerfile
LLM.md
app/ routes + global.css (the whole stylesheet)
k8s/
lib/
next-env.d.ts
next.config.ts
package.json
pnpm-lock.yaml
public/
tsconfig.json
Key Files
package.json-- Dependencies and scriptsDockerfile-- Container buildapp/global.css-- tokens + the exact utility vocabulary the markup useslib/money.ts-- the ONE money formatterlib/config.ts--BASE_PATH(the app's own root) andCOMMERCE_URL, the ONE commerce address every money-in surface asks — the card form reads its Square application from it and the charge is sent to it, so two spellings would be two Square accountslib/topup.ts-- the standing top-up amounts andtopupHref(), the ONE way to link to/topup(it carries?amount=so a click never drops what it knew)lib/charge.ts-- the ONE idempotency key per money-move attempt, shared by the top-up and subscribe card paths
Styling (one way)
Plain CSS. app/global.css is the entire stylesheet: two @imports, then
app-local aliases on :root, then ordinary rules for the exact utility class
vocabulary the markup uses. There is no build plugin, no postcss.config, no
tailwind.config — do not reintroduce one.
@import '@hanzo/ui/styles.css'; /* tokens AND handles — one home */
@import '@hanzo/design/tokens/fonts.css'; /* the two @font-face + --font-* */
@hanzo/ui/styles.csscarries design's ladder inlined verbatim (88 tokens, byte-identical) plus the handles. It is the only colour import.- Fonts must be imported or nothing fails LOUDLY.
@hanzo/ui8.0.47 removed its own@font-face(it duplicated design's and pointed at assets ui never shipped), so ui only NAMES the faces —.monoasks for the literal family'Zen Mono'. Without design'sfonts.cssthe build is green, gui-css-check is green, and every money figure silently falls back toui-monospace: still monospace, still tabular, wrong face, invisible in a screenshot. Do not usenext/fonthere — it mints a scoped family name (__Geist_Mono_abc123) that.monocan never match. That was the bug.
--page: the one token taken back from gui
The page ground reads --page, never --background. This is deliberate and
temporary, and it is the same answer hanzo.industries reached:
:root { --page: var(--neutral-950); } /* #0A0A0A — design's dark ground */
.light { --page: var(--pure-white); } /* #FFFFFF — design's light ground */
@hanzo/ui/styles.css inlines design's ladder, then ~160KB later the @hanzogui
theme runtime redeclares --background for its own themes — :root.t_dark →
var(--t8), :root.t_light → var(--t189) = hsla(0,0%,97%) — each with a
bare :root fallback beside it, light block last. So with no gui class the
ground is near-white in both themes and dark measures 1.03:1.
Re-importing design's colors.css last reclaims it (1.0.9 shipped that) but
only while no gui theme class exists on <html>. :root.t_light is (0,2,0),
design's .light is (0,1,0) — the moment GuiProvider writes its class, gui wins
on specificity and import order stops mattering. Measured on 1.0.9 with the class
forced on: dark+t_light 1.03:1, light+t_dark 1.07:1, and even the
agreeing pairs drift (t_dark grounds at #141414). A fix that holds only while
a different bug stays unfixed is not a fix.
--page is a name gui never writes at any specificity, resolving to design base
rungs (--neutral-950, --pure-white) that are each declared exactly once in
the whole delivered sheet. Verified in all four combinations of {dark, light} ×
{gui class, none} — plus the two mismatched crosses — at a constant 18.97:1
dark and 19.80:1 light.
Only --background needs this. --foreground, --card, --accent,
--border*, --primary*, --text-* and --state-* were all measured unchanged
with the gui classes forced on, so they read ui's inlined ladder directly and are
not restated here. Do not "defensively" wrap the others; that would be a second
mechanism for a problem they do not have.
Still needed at @hanzo/ui 8.0.72. The removal condition was "once ui stops
shadowing design's --background / --black / --white", and it has not:
8.0.72's sheet still carries the gui theme runtime and declares --background
246 times. Re-measure before deleting --page; do not delete it on the version
number alone.
The focus ring — ONE, and it is not ours
A focused field draws exactly one indicator and billing does not own it:
@hanzo/design's base layer states :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px } for every focusable thing, field included.
It is --ring at .40 (3.77:1 on the darkest canvas, the one contrast pair the
design package gates), it is :focus-visible so a mouse click does not ring it,
and an outline is the only affordance an app's own border styling cannot
silently override.
Never put outline-none, focus:border-* or focus:ring-* on a field here.
All sixteen inputs did, and it cost twice over: outline-none deleted the shared
ring, and what replaced it was either a second ring stacked on the first
(focus:border-brand focus:ring-1 focus:ring-brand) or — on the three fields
carrying an inline outline: 'none' — no focus indicator at all. Deleting the
overrides is the whole fix; there is nothing to add.
The reason a stale lockfile shows a DOUBLE rather than a single ring:
@hanzo/ui/styles.css inlines design's base layer verbatim at ui build time, so
the version of that layer an app renders is decided by its @hanzo/ui pin, NOT
its @hanzo/design pin. Before design 0.4.9 the layer held two rules — a field
rule (outline:none + brightened edge + halo) and the generic ring — that both
computed to (0,1,0), so the cascade fell through to source order, the generic
rule was written later, and it beat the outline:none the field rule stated to
prevent exactly that. Both rules then painted. design 0.4.9 deleted the field
rule; @hanzo/ui 8.0.69 was the first release to re-inline the fixed layer.
Money must be tabular. Numeric cells carry mono (Zen Mono +
font-variant-numeric: tabular-nums); tnum is the same tabular figures
without changing the face. Both come from @hanzo/ui — do not re-declare a
local .font-mono or .tabular-nums, which is what they replaced.
Four colour facts, each with ONE home. Markup must never spell a colour:
| fact | token | utilities |
|---|---|---|
| ground | --color-bg (and --color-bg-input) read --page, see above · --color-bg-card → --card · --color-bg-elevated → --accent |
bg-bg · bg-bg-card · bg-bg-elevated |
| ink | --color-text (+ -secondary -muted -dim) |
text-text*, and the *-ink/α family — bg-ink/[0.06], border-ink/[0.08], text-ink/40 — which is ink mixed at α, NOT literal white |
| brand | --color-brand · --color-brand-foreground · --color-brand-hover — all three alias design's --primary family, NOT its --brand family. --primary is the ink of the current theme and inverts; --brand is a fixed accent that does not restate under .light, so aliasing to it gave a light-grey button on a white page whose hover was one shade off itself. |
bg-brand · text-brand-foreground · hover:bg-brand-hover |
| status | --color-success -warning -danger -info |
text-success, bg-warning/15, … |
Inline style={{}} obeys the same rule — color: 'var(--color-text)',
background: 'var(--color-bg-card)', and color-mix(in oklab, var(--color-text) 6%, transparent) for a tint. A literal there is how /commerce and /topup
stayed dark through a theme flip.
Colours that are deliberately literal, and why:
- the four buttons whose ground is a status colour in both themes (emerald /
sky / rose / success) — there
text-whiteIS the right value; - status hues themselves (
#f87171,#34d399,#4ade80); lib/config.tsper-org brand palettes (data, not theme);app/layout.tsxviewport.themeColor(aprefers-color-schememeta pair);lib/wallet-providers.tsxRainbowKitdarkTheme();lib/square.tsCARD_STYLE— the card fields keep SQUARE's colours, not ours, and that is the fix rather than a gap. Square paints them in a cross-origin iframe on a white surface it will not let us repaint:.input-containerbackgroundColor is rejected outright ("Invalid style property", which fails the whole attach), and setting it oninputdoes nothing. So an ink chosen from our theme is invisible in whichever theme is light-on-light — this shipped as a hard-coded#fafafa, which was ghost text on white in the DEFAULT dark theme, not just in light. Square's own defaults are dark-on-white and internally consistent, so they read identically under both themes. We set the font size and the validation-message colour, nothing else, and the hook therefore has no theme dependency — one that re-attached on a flip would discard whatever the customer had already typed.
A class with no rule in any delivered sheet is silently unstyled: same failure
mode as an unrecognised style prop. postbuild runs gui-css-check so that
cannot ship green again — it compares every class in the rendered markup against
the selectors in every sheet the page delivers, linked .css and inline
<style> alike, and exits non-zero on a miss.
One caveat worth knowing: this app is a client-rendered static export, so the
HTML on disk is a near-empty shell and the postbuild run only sees the ~24
classes in it. The authoritative run renders the app in a browser first. To
reproduce it: serve out/, seed a token in localStorage, walk the ten section
hashes at 390 and 1280 in both themes, write each document.documentElement .outerHTML to a directory, copy out/_next/static/chunks/*.css alongside, and
point gui-css-check at that directory. The convergence run was 40 pages,
218/218 classes covered.
Because the SPA mounts at two roots (/admin/billing embedded in commerce, /
standalone), every self-navigation goes through BASE_PATH — a bare '/'
walks the browser out of the embedded app.
Free credit is retired — do not restore it
There is no starter credit: no auto-grant on first visit, no claim button, no
grantStarterCredit on the commerce client. Onboarding at hanzo.id requires a
payment method AND a plan, so nothing here may advertise credit or call paying
optional. The guided setup card has no "Skip setup" — for an UNFUNDED account
the overview IS that card until both required steps are done (setupIncomplete
in billing-shell.tsx), and every other section stays reachable from the
sidebar. A FUNDED account (positive available balance or credit) always sees
the dashboard: it has already established a way to pay, its money is the honest
thing to show, and walling it behind "choose a plan" once trapped a six-figure
prepaid account in onboarding and swallowed its Add-credit click. The card only
stops CLAIMING setup is optional; enforcement is server-side in commerce, not
here.
Accounts that already hold a starter credit keep it. It is a real Deposit
transaction tagged starter-credit, and loadBillingData still synthesizes a
grant row from it so the money stays visible — showing a past grant is not
advertising a current offer.
A discount is server-computed — there is no code to type
The one discount mechanism is the platform promo: a SuperAdmin sets
{percentOff, start, end, plans, active} at PUT /v1/admin/promos, commerce
annotates each covered plan with promoPercent, and subscribe_card.go
subtracts it from the charge. lib/money.ts chargeTotal mirrors that
arithmetic so the button quotes the number the card is charged. It carries no
code and nobody redeems it — a covered plan is discounted with nothing typed.
Do not add a code field. Three shipped here and none could work, because the
fleet routes none of the addresses they called — /v1/coupon/validate,
/v1/billing/discount/validate and /v1/billing/subscriptions/{id}/promotion
are all absent from GET /v1/openapi.json, whose /v1/billing product publishes
38 concrete paths and no catch-all to hide behind. The hero field on /commerce
also held its code in local state and handed it to nothing, so a successful
validation would have said "Code applied" over a cart that never saw it — worse
than the red "Unable to validate coupon right now" it actually showed, which
reads as a passing outage and was permanent.
Redeemable codes are not a thing we sell: cloud's apps/marketing/promos.go
compiles campaignsLive = false (read from no env var, no column, no flag),
purges the one campaign row on every boot, and states that credit enters an org
only by a manual admin grant. /v1/commerce/discount is a merchant tenant's
price rules for their own store, not ours, and it has no validate verb either.
lib/billing/discount-surface.test.ts holds the line: no placeholder may
invite a code, the commerce client may name none of those three addresses, and
chargeTotal must still subtract promoPercent. On the day a server evaluates
a code, that test changes in the same commit as the route.
Payment flows (one way each)
-
Money is formatted in ONE place:
lib/money.ts—money(value, currency, digits)andmoneyFromCents(cents, …).digitsis the only thing call sites ever disagreed on (catalog prices are whole dollars, ledger amounts are to the cent). Do not add a localfmt/formatCurrency; there were fourteen. -
Square tokenization lives in ONE place:
lib/square.ts(useSquareCardanduseApplePayhooks +squareConfig+loadSquareScript+CARD_STYLE). All money-in flows reuse it — the top-up page (app/topup/page.tsx) and paid-plan checkout (lib/billing/subscribe-card-form.tsx). Card data never touches our code; the card fields and the Apple Pay sheet both return a single-usesourceIdnonce the server charges by the identical call. Which Square is the ORG's answer, not the build's:squareConfig()readsGET /v1/billing/settingsper card mount and uses application + location + environment from it, so a sandbox org and a live org are served by one image. The bakedNEXT_PUBLIC_SQUARE_*are the fallback for when that read fails, and a partial answer falls back WHOLE — pairing one environment's application with another's CDN is exactly the cross-environment mismatch Square rejects. Every surface that tokenizes askssquareConfig()— the wallet included.useApplePayresolves the account through it and passes that answer to bothloadSquareScript(environment)andpayments(), so the sheet and the card fields can never name different Square accounts. Wiring the wallet to the baked constants instead would mint a nonce against one environment for a charge sent to another, which is the rejectionsquareConfig()exists to prevent. -
Crypto top-up is the MPC custody rail and nothing else: the
/topupcrypto tab lists what commerce is watching (GET /v1/billing/crypto/options), asks for this payer's address on the chosen pair (POST /v1/billing/crypto/deposit), and shows it with a QR. Nothing in the tab can credit a balance — a chain watcher does that on confirmations — so nothing in it says a deposit has landed. Where commerce issues a destination tag (XRPL) the QR is withheld: an address alone is half that destination, and the half that arrives belongs to nobody. -
Paid-plan self-serve checkout: SubscriptionPortal → SubscribeCardForm →
commerce.subscribeWithCard({ sourceId, planId, quantity, currency }, { idempotencyKey })→POST /v1/billing/subscribe/card(commerce ≥ v1.49.5). Server-authoritative price × max(1, quantity); NO client amount. Idempotency key is per attempt in sessionStorage (hanzo_sub_idem:<plan>:<qty>); kept on transient errors, rotated on decline, cleared on success. Error UI branches on status: 402 declined · 409 in-progress · 422 no processor (→ contact fallback) · 500 charged-but-activation-failed. Free tiers still usePOST /v1/billing/subscriptions; platform admins keep the mint path (canDirectSubscribe). -
Public pricing (logged-out, no login wall):
app/page.tsxrendersPublicPricingwhen unauthenticated — auth gates only the subscribe action (onRequireAuth→ start sign-in). Plans come fromlib/plans.tsPUBLIC_PLANS_URL = https://api.hanzo.ai/v1/plans/subscriptions(the GATEWAY, not api.cloud.hanzo.ai — cloud is public but emits no CORS header; the gateway proxies the same catalog AND returnsaccess-control-allow-origin: https://billing.hanzo.ai). Public catalog prices are DOLLARS; commerce's are cents. -
A chosen plan travels intact:
/?plan=<slug>is what every "Get started" on the marketing site links to.app/page.tsxreads it once (static export —URLSearchParams(window.location.search), theapp/topupprecedent) and hands it toSubscriptionPortalaschosen; the portal waits for the catalog, opens the tab the plan lives on, and runs the SAMEhandlePlanChangea click runs. A slug the catalog does not hold changes nothing.handlePlanChange(plan, pressed)— a press may FINISH (charge a card on file, or thecanDirectSubscribemint path); a plan carried in may only OPEN (the Pay card form, or sign-in). A link that spends money is a link anyone can send.- Sign-in keeps it:
startLogin(conf, resume)remembers the query and hash,resumed()hands them back at/auth/callback. Only a?/#suffix is ever stored, so nothing in it can name another path or host. - Downstream is unchanged and already correct:
pay.<brand>/confirm/card?plan=reads the slug, carries its own sign-in (startLogin({redirect: here})keeps path+search+hash), andPOST /v1/billing/subscribe/cardprices theplanIdserver-side. The slug is the only thing that travels — never an amount.
Testing money flows in sandbox
Money-in can be exercised end to end with FAKE money. Nothing below changes a production CR, and none of it needs a deploy.
The authority is the ORG, not the deployment. commerce resolves sandbox vs
production from the org record alone — organization.TestMode() is !o.Live
(models/organization/testmode.go), and that ONE fact picks both the credential
set (payment/orgsetup.go → SQUARE_SANDBOX_ACCESS_TOKEN /
SQUARE_SANDBOX_LOCATION_ID) and the ledger bucket (trans.Test in
api/billing/payment_core.go). So a Live=false org transacts in Square sandbox
and credits the TEST balance, on the same replica that serves live merchants. A
sandbox charge can never mint spendable credit, and production needs no change to
serve one.
And the SPA follows the org. lib/square.ts asks
GET /v1/billing/settings (commerce's payment.SquarePublicConfig) on each card
mount and tokenizes with the application, location and environment it answers, so
billing.hanzo.ai serves sandbox and production from ONE image: flip an org's
Live flag and the next card form loads the other Square. The baked
NEXT_PUBLIC_SQUARE_* apply only when that read fails, and then all three
together — filling one field from the build and the rest from the org is how a
nonce ends up minted against the wrong environment, which Square rejects.
This works on the deployed cloud because the pod carries the sandbox PUBLIC pair
(SQUARE_SANDBOX_APPLICATION_ID, SQUARE_SANDBOX_LOCATION_ID) beside the
production one; without them a test org would be answered environment: sandbox
with the production application id — the same mismatch, now server-side.
Credentials
Sandbox app id and location id are PUBLIC — Square hands them to the browser. Commerce serves them per org, so nothing here needs them; they are only worth setting for a build that must work with no commerce to ask:
NEXT_PUBLIC_SQUARE_ENVIRONMENT=sandbox
NEXT_PUBLIC_SQUARE_APPLICATION_ID=sandbox-sq0idb--kZP78n8_TjQOu8qApr5iQ
NEXT_PUBLIC_SQUARE_LOCATION_ID=LD8DWE4KKB0WB
The server-side access token is the only secret. It lives in Hanzo KMS, project
hanzo-commerce, env prod, path /, key SQUARE_SANDBOX_ACCESS_TOKEN,
synced by universe/infra/k8s/commerce/kms-secrets.yaml into the commerce-secrets
Secret in namespace hanzo. Never paste the value into a file, a build arg, or a
commit — read it at point of use:
kubectl get secret commerce-secrets -n hanzo \
-o jsonpath='{.data.SQUARE_SANDBOX_ACCESS_TOKEN}' | base64 -d
Test card
4111 1111 1111 1111 · any future expiry · any CVV · ZIP 94103.
Square's sandbox also accepts the pre-made nonce cnon:card-nonce-ok as a
sourceId, which is how the charge leg is proven without a browser.
Run the card flow
Sign in as a member of a Live=false org, open /topup, enter the test card,
submit: the SPA tokenizes with that org's sandbox application and calls
POST /v1/billing/topup/token with the nonce. Read the balance either side with
GET /v1/billing/balance. The credited subject is the org slug, resolved
server-side — a body value cannot steer it. billing.hanzo.ai does this as well as
a local run does; the org is the whole switch.
Locally, Square's Web Payments SDK refuses to load over plain http on a
non-localhost origin. http://localhost:3005 is exempt, so pnpm dev needs no
certificate; anything else does (mkcert).
cd ~/work/hanzo/hanzoai/billing
echo 'NEXT_PUBLIC_BASE_PATH=' > .env.local
pnpm dev # http://localhost:3005/topup
.env.local outranks .env.production and is git-ignored, so the committed live
defaults are never mutated. The Docker build args (SQUARE_APPLICATION_ID,
SQUARE_LOCATION_ID, SQUARE_ENVIRONMENT) still write .env.production.local,
which now only decides what the card form does when it cannot reach commerce.
Verify the charge leg alone
Proves the KMS token and location take money, no browser involved:
TOK=$(kubectl get secret commerce-secrets -n hanzo \
-o jsonpath='{.data.SQUARE_SANDBOX_ACCESS_TOKEN}' | base64 -d)
curl -s https://connect.squareupsandbox.com/v2/payments \
-H "Square-Version: 2025-01-23" -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' \
-d '{"source_id":"cnon:card-nonce-ok","idempotency_key":"probe-'"$(date +%s)"'",
"amount_money":{"amount":2500,"currency":"USD"},"location_id":"LD8DWE4KKB0WB"}' \
| jq '{status:.payment.status, id:.payment.id}'
Verify the ledger leg alone
The Square call is provider-owned; the ledger invariants are pinned
deterministically in hanzoai/commerce. Build the way the product ships —
CGO_ENABLED=0 — because hanzoai/base deliberately fails a cgo build that
lacks sqlite_math_functions:
cd ~/work/hanzo/hanzoai/commerce
CGO_ENABLED=0 go test ./api/billing/ -run TestTopup -v
TestTopup_SandboxChargeCreditsTestBucket drives the real handler for a
Live=false org and asserts the test bucket rises while the live bucket stays
empty.
Crypto rides the MPC custody rail
The crypto tab is GET /v1/billing/crypto/options for what commerce is watching,
POST /v1/billing/crypto/deposit for this payer's address on the pair chosen,
and a chain watcher that credits only on confirmations
(commerce/api/billing/crypto_deposit.go). The SPA cannot credit anything, and
says nothing that implies it has.
Two properties hold it together, both server-side:
- Offered ⟺ creditable. The picker renders only what
optionsanswers, and that set is projected from the watcher's configured assets — an asset appears because something is reading the chain for it, and stops the moment that stops being true. A failed options call therefore renders NOTHING; a fallback list would be an invitation to send money at an address nobody watches. - One payer, one address per asset. A re-POST returns the open intent rather than minting again, so re-opening the tab strands nothing.
Arming an asset is one act on the serving deployment: CRYPTO_DEPOSIT_RPC_<CHAIN>
plus CRYPTO_DEPOSIT_TOKEN_<CHAIN>_<TOKEN> (and CRYPTO_DEPOSIT_ADDRESS_<CHAIN>
where deposits are pooled). Nothing is watched by default, so the rail is closed
until it is named. app/topup/page.tsx holds the labels for the pairs worth
naming — a row there is copy, never permission; visibility is the server's answer.
Where commerce issues a destination tag (a pooled chain such as XRPL) the tab shows the tag and withholds the QR: the address alone is half the destination, and the half that arrives names nobody.
What this replaced, so it is not rebuilt: a form that took a user-typed tx hash
and posted it to POST /v1/payments — a GENERIC REST resource
(rest.New(payment.Payment{}) in commerce/api/api.go) with no create hook. It
verified no hash, credited no balance, hardcoded network: 'ethereum' whatever
chain the customer used, then read the balance back and announced a successful
top-up. The treasury address it published came from NEXT_PUBLIC_HANZO_ETH_TREASURY
(no longer read anywhere).
License
Dual-licensed MIT OR Apache-2.0 (LICENSE-MIT, LICENSE-APACHE), replacing the
previous BSD-3-Clause declaration. Original Hanzo work standardises on this pair per
HIP-0137 "One License" (hanzoai/hips, HIPs/hip-0137-one-license.md); forks keep
their upstream licence unchanged.