Skip to content

Payment gateway design

Modular by design

Payment processing is a pluggable provider, chosen in the Setup area — not a hardcoded dependency. The pieces:

  • payment_gateway table — one row per configured provider (provider, display name, non-secret config JSON, 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_txn table — 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.

Setup → General → Payments

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.