@hanzo/pay
v0.1.2
Published
Square payments for web and native behind one interface. Card, Apple Pay, Google Pay, Cash App Pay, ACH and gift cards, feature-detected — with one colour table that dresses the web iframe and the native sheet alike.
Readme
@hanzo/pay
Square payments for web and native, behind one interface.
pnpm add @hanzo/payThe shape
Three entries. The root is platform-agnostic and dependency-free — contracts, the colour table, the idempotency-key rule — so a server, a shared module and a test can all hold it. The adapters are explicit:
| Entry | Needs | Gives you |
|---|---|---|
| @hanzo/pay | nothing | Terminal, Method, Tender, Token, PALETTE, legible, attempt |
| @hanzo/pay/web | a browser | terminal() over the Web Payments SDK, style(), pin() |
| @hanzo/pay/native | react-native-square-in-app-payments | terminal() over the In-App Payments SDK, appearance() |
Both adapters export terminal(config) and both return the same Terminal.
Picking one by filename magic works right up until a bundler picks the other,
and then it fails at runtime, in a payment, on someone else's device — so the
host says which platform it is, in the import.
Taking a payment
import { cancelled, attempt } from '@hanzo/pay'
import { terminal } from '@hanzo/pay/web'
const pay = await terminal({ applicationId, locationId, environment })
const tender = { total: { amount: '12.00', currency: 'USD' }, country: 'US', label: 'Credit' }
const rails = await pay.offers(tender) // only what can really pay, here, now
await pay.mount?.('card', '#card') // absent on native: there is no element
const key = attempt() // hold this across retries
try {
const token = await pay.collect('card', tender)
// charge server-side with token.value and `key` as the idempotency key
} catch (e) {
if (!cancelled(e)) throw e // a dismissed sheet is not a failure
}This package never moves money. Every rail ends at a single-use token; the charge is a server call you make with it.
What it knows that you would otherwise learn the hard way
offers()asks the SDK, one rail at a time. Availability depends on the browser, the device, the buyer's saved cards and whether the merchant account has the rail switched on. A picker listing a wallet the platform cannot use is a dead end with a logo on it.REACH.nativehas no Cash App Pay and no ACH. Square's native SDK ships neither. That is reach, not policy — a host may decline a rail this table lists, andhanzoai/paydeclines ACH on Square because its top-up endpoint credits on charge success while an ACH debit settles days later.- Nothing is awaited before
tokenize(). Apple refuses a sheet not opened by the gesture that asked for it, sooffers()builds every rail up front andcollect()only reads a map. One strayawaitabove that call breaks Apple Pay and nothing else. - Cash App Pay and ACH deliver on an event, not a return value. A caller reading only the return gets nothing from either, silently, on a payment that succeeded.
pin()is applied for you.input.backgroundColoris accepted and then ignored:color-schemeinherits into the cross-origin iframe and the UA paints form controls over whatever the SDK declared. One rule fixes it, and it has to be in the sheet before the card attaches — soterminal()puts it there rather than leaving a stylesheet you can forget to import. It carries the FOCUS RING too, for the mirror-image reason: a ring is drawn outside the field, and everything Square will style for you stops at the field's edge. Callpin()yourself only if you drivepayments.card()directly instead of taking aTerminal.attempt()fits Square's ruler. Anidempotency_keyover 45 characters is rejected before authorization. A 36-char UUID under atopup:<org>:wrapper is 49, which failed every card charge for one org and read as "charge failed".Detail.chargeholds the native sheet open. Square's native card sheet stays up after minting the token and is told afterwards whether the charge worked — so a decline retries in place instead of dumping the buyer back to an empty form. The web has no equivalent and ignores it.
Theming
One Palette dresses both platforms: style() renders it as the CSS literals
the web iframe takes, appearance() as the {r,g,b,a} components the native
sheet takes. Neither surface can read your CSS variables — one is cross-origin,
the other is not the web — which is why the table holds literals.
import { legible, PALETTE } from '@hanzo/pay'
legible(PALETTE.dark) // [] — every contrast floor met
legible({ ...PALETTE.dark, field: '#fafafa' }) // names the pairs that failRun legible() over a brand palette before shipping it. A reviewer looking at a
dark mock cannot tell 2.6:1 from 4.4:1, and the focus ring is a WCAG 2.4.11
requirement rather than a preference.
Set field and border to what your own inputs COMPOSITE to, measured in a
browser. The card strip lands in a column of your fields and is read as one of
them or as a foreign object; there is no third outcome. Ours shipped on --card
and its hairline — real tokens, for a card — so the strip rendered a lighter
ground behind a fainter edge than every field above it.
ring is the one focus indicator, and the box does not move. pin() draws it
around the container as an outline, so it is the same ring your page already puts
on its own controls; style() therefore paints the FOCUSED hairline the resting
colour, because Square spends that value twice (the border and a ::before ring
one pixel outside it) and any other value is a second ring inside the first.
Native setup
@hanzo/pay/native needs react-native-square-in-app-payments and the native
build steps that plugin documents (Apple Pay entitlement and merchant id, Google
Pay via Play Services). appearance() is iOS only — Android's card entry is
themed through native XML styles at build time, with no runtime channel.
License
Apache-2.0
