Payment gateway design¶
Modular by design¶
Payment processing is a pluggable provider, chosen in the Setup area — not a hardcoded dependency. The pieces:
payment_gatewaytable — one row per configured provider (provider, display name, non-secretconfigJSON,is_default). The Stripe secret key is either pasted in Setup → General → Payments (stored encrypted server-side) or supplied via the server's.env— the Payments tab shows which source is live. Never in the repo.- Gateway interface in
apps/server— every provider implements the same small contract:createPayment(amountCents, readerId) → paymentRef,getPaymentStatus(paymentRef),cancelPayment(paymentRef),refund(paymentRef, amountCents),listReaders(). payment_txntable — gateway-agnostic record per card tender: provider, processor reference, brand, last4, tip, refund flag. No PAN/expiry/CVV, ever (PCI-light).
The till never talks to a processor. It asks our server to take a payment; the server routes
to the active gateway. Adding Square/Moneris/etc. later = one new adapter file + a
payment_gateway row.
Implemented (2026-07): the gateway interface (apps/server/src/gateways/), the payment
routes (POST /api/payments, GET /api/payments/:id, POST /api/payments/:id/cancel), the
till's waiting/cancel flow, and two adapters — mock (default in dev; approves after 1.5 s)
and stripe (server-driven Terminal, below). The operator-facing switch lives in
Setup → General → Payments: pick the default gateway and paste the Stripe key there —
no config files.

Dev alternative: put STRIPE_SECRET_KEY in
apps/server/.env (see .env.example), run npm run stripe:setup --workspace apps/server
(creates a Location + simulated reader and makes Stripe the default gateway), restart. In
test mode, simulate the customer's tap via POST /api/test/present-card
{paymentId, tipCents?, interac?} — hardware registers through the same setup path later.
Stripe Terminal adapter (first real provider)¶
Research digest from https://docs.stripe.com/terminal (July 2026). Full detail in the planning brief; the load-bearing decisions:
Integration path: server-driven (not the Terminal JS SDK). Our server calls Stripe's API
and Stripe's cloud relays commands to the reader — the browser and reader never talk to each
other. This avoids the JS SDK's LAN-DNS trap (resolving *.stripe-terminal-local-reader.net
to local reader IPs), keeps all Stripe secrets server-side, and uses the identical code path
against the simulated reader. Trade-off: card payments require internet (true for smart
readers generally; cash keeps working — the local-first guarantee is about the POS, not card
auth).
Payment flow:
1. Till → server: POST /api/payments {saleId, amountCents}.
2. Server → Stripe: create PaymentIntent — currency=cad,
payment_method_types=[card_present, interac_present], capture_method=automatic
(manual capture hard-declines Interac).
3. Server → Stripe: POST /v1/terminal/readers/{reader}/process_payment_intent.
4. Reader shows amount → tip screen (customer-facing — this is where tips happen) →
tap/insert/PIN → auto-capture.
5. Server polls the reader's action.status (~1s) until succeeded/failed — polling, not
webhooks: the server sits on a LAN behind NAT, and Stripe documents polling as
first-class. A periodic PaymentIntent reconciliation sweep catches anything a crashed poll
missed.
6. Persist to payment_txn: PaymentIntent + charge ids, brand + last4 (from
payment_method_details.card_present or .interac_present — both rails), tip from
amount_details.tip.amount.
Tipping: configured in a Stripe terminal.configuration (per Location) — e.g. 15/20/25%
suggestions with a smart threshold; currency must be cad or the tip screen silently skips.
process_config[skip_tipping] per transaction when needed. Never entered at the till.
Voids/refunds (tender-aware reversal):
- Credit (card_present): plain POST /v1/refunds against the original PaymentIntent — no
card needed.
- Interac (interac_present): refund requires the original card present at the reader
(POST /v1/terminal/readers/{id}/refund_payment). The void flow must branch: if the stored
rail is Interac and the customer isn't standing there, fall back to cash.
Interac facts (Canada): CA$0.15 flat per transaction (vs 2.7%+$0.05 credit) — debit is
dramatically cheaper; no offline; contactless caps (>$100 or 4th consecutive tap forces
insert+PIN, reader handles it); never call /capture on an Interac PI.
Development without hardware: register a simulated reader
(registration_code=simulated-wpe), drive the normal flow, then
POST /v1/test_helpers/terminal/readers/{id}/present_payment_method with test cards
(declines, PIN cards, Interac 4506445006931933) and a simulated amount_tip. Same code as
production.
Server endpoints to add with the adapter: POST /api/payments, GET /api/payments/:id,
POST /api/payments/:id/cancel, POST /api/refunds, GET /api/readers (+ per-station reader
assignment in Setup).
Hardware note (verified 2026-07): buy SMART readers only. All Stripe smart readers sold in Canada work with this server-driven design — Reader S700 (countertop, ~CA$419), S710 (+cellular), BBPOS WisePOS E (handheld), and the Verifone line (V660p, UX700, P630, M425; public preview in CA). The mobile/Bluetooth readers (Reader M2, WisePad 3) do NOT work — they require native iOS/Android SDKs and can't be driven by a server or browser. Rule of thumb: own touchscreen + WiFi/Ethernet = compatible; Bluetooth puck = not.