POS card terminals & ECR Hub (PAX focus)
Executive summary
Section titled “Executive summary”LionPOS already supports card orders that wait for a physical POS terminal to settle payment: the browser polls payment status while a server-to-server webhook finalizes success or failure. The repository now ships a reusable @indochina/ecr-hub package (under packages/ecr-hub) that standardises integration with two terminal families:
- PAX (TCP) —
STX/ETX/FS/LRC-framed semi-integration over a LAN socket (used byvendor-desktopand the terminal lab). - CodePay (WebSocket) — JSON envelopes with
ecrhub.*topics over the bridge WebSocket (used byvendor-webterminal lab and POS bridge agent).
Both wire formats are abstracted behind a single canonical contract so callers issue the same EcrHubCommand (e.g. SALE, RETURN, VOID_SALE, TIP_ADJUSTMENT, BATCH_CLOSE, REPORT, PRINTER, SIGN, ABORT) and receive a single EcrHubResult. Production use still requires a bridge (middleware, gateway, or PAX cloud callback) that translates terminal outcomes into the existing POST /api/v1/pos/payments/terminal-webhook contract and authenticates with the per-store x-pos-terminal-key — the ECR Hub gives us a unified shape to feed that webhook regardless of manufacturer.
This page describes how the platform works today, the new ECR Hub layering, how PAX and CodePay fit, what to build next, security expectations, and a checklist plus test scenarios for delivery. CodePay wire format, topics, CodepayAdapter, and the vendor-web lab are documented in POS card terminal — CodePay (ECR Hub).
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals (for this design doc) |
|---|---|
| End-to-end card flow: order → terminal → webhook → paid/failed UI | Choosing a single acquirer or PAX reseller |
| Clear ownership: terminal bridge vs backend vs vendor POS | Full PCI DSS audit write-up (reference your QSA) |
| Idempotent, auditable callbacks | Shipping PAX proprietary SDK code in this monorepo without a vendor contract |
Current platform behavior (reference implementation)
Section titled “Current platform behavior (reference implementation)”Vendor POS (browser)
Section titled “Vendor POS (browser)”- Staff place a card order; the UI enters a pending terminal payment state. After the order row is committed, the SaaS API does not open TCP to a store LAN terminal. It emits
payment_requeston the POS bridge WebSocket so vendor-desktop (or another bridge client on the store network) can run local PAX TCP (SALE, cancel/abort, etc.) and report outcomes viapayment_resulton the socket or the HTTP terminal webhook.POST /pos/ordersreturns immediately withpayment.required/ pending status and a digits-onlypayment.transaction_ref(timestamp ms + random digits) stored on the order for terminal correlation.POST .../payment-intent(retry) does not overwrite an existing ref; the acquirer auth code may still arrive on webhook success. - The SPA polls
GET .../pos/orders/:orderId/payment-statusuntilpayment_statusreflects a terminal outcome (or the operator cancels). - Retry after decline: if the last attempt left
order_status: payment_failed(e.g. TCPSALEdeclined), callingPOST .../payment-intentagain resetsorder_statustoawaiting_confirmationand clears the relatedorder_transactions.statusmarker so the client response andpayment-statuspolling are not permanently stuck on failure.
Backend (NestJS PosModule)
Section titled “Backend (NestJS PosModule)”| Concern | Route / behavior |
|---|---|
| Intent + polling | POST /api/v1/pos/orders/:orderId/payment-intent, GET /api/v1/pos/orders/:orderId/payment-status |
| Terminal callback | POST /api/v1/pos/payments/terminal-webhook — body: TerminalPaymentWebhookDto; header: x-pos-terminal-key |
| Auth model | Webhook is not a user JWT. x-pos-terminal-key may be the store_devices.api_key of a payment_terminal row, or the store’s stores.pos_bridge_api_key; when using the store bridge key, the JSON body must include terminal_id (store_devices.id) so the server can pick the correct payment terminal. |
POS bridge WebSocket (activate / WS)
Section titled “POS bridge WebSocket (activate / WS)”A POS bridge client (for example vendor-desktop as the POS bridge agent, or lab tools under vendor-web → Settings) uses the following contract end to end—no separate middleware process is required for pairing and real-time messaging:
| Step | API / channel |
|---|---|
| Pairing (public) | POST /api/v1/pos-bridge/activate — body: storeCode, registerCode, pairingToken (must match store_devices.api_key for a payment_terminal on that store), optional deviceSerial / deviceModel. Response includes wsUrl with storeId + token = stores.pos_bridge_api_key (provisioned eagerly when the store is created via admin / vendor flows; legacy stores fall back to lazy generation in PosBridgeService.ensureStorePosBridgeApiKey), webhookUrl (same terminal-webhook path as above), and terminalId for display/routing. Legacy clients may still open /pos-bridge/ws?terminalId=<store_devices.id>&token=<device api_key>; the server accepts both. |
| Pairing (vendor dashboard) | POST /api/v1/vendor/settings/devices/pos-bridge/activate — vendor JWT + settings module; body: deviceId (store_devices.id from Vendor → Settings → Devices), registerCode, optional deviceSerial / deviceModel. Same response as public activate; the server reads api_key from the saved terminal row (no pairingToken in the body). In vendor-web, each saved payment-terminal card (Activate POS bridge) calls this and persists wsUrl, LAN host/port from the device row, webhookUrl, terminalId, and last registerCode to browser localStorage (vendor.posBridgeAgent.*.v1) for the POS bridge agent (desktop) screen and a per-store / per-device snapshot (nipos.vendor.posBridge.deviceSnapshots.v1) used to reconnect every activated terminal after a restart. |
| Batch close (vendor dashboard) | POST /api/v1/vendor/settings/devices/payment-terminal/close-batch — vendor JWT + settings; body deviceId (store_devices.id). Dispatches BATCH_CLOSE through ECR Hub over the same bridge relay as card payments; Vendor Desktop must be connected for that terminal’s lane. vendor-web exposes this on Terminals → detail when the active bridge agent target matches the device. |
| Multi-terminal (vendor-desktop) | Preferred: one WebSocket per store (storeId + store bridge key) so vendor-desktop runs one agent per store (store:<id> session key). Legacy: a set of sockets per terminalId on the URL. Vendor-web auto-start and ensure helpers treat session keys store:… / terminal:… so mixed snapshots still reconnect without duplicate store connections. |
| Real-time | WebSocket on /api/v1/pos-bridge/ws — after connect, server sends bridge_ready with either storeId or terminalId, ts, and activeConnections. Clients send bridge_heartbeat; heartbeat_ack may echo storeId for store sockets. Disconnect rules: last socket on a store bridge clears is_connected for all bridge device rows in that store; last per-device socket clears that row only. payment_request, ecr_relay_request, and printer pushes prefer the store socket when connected, with storeDeviceId in the JSON when a specific store_devices.id is targeted. printer_print_request (from POST …/vendor/settings/devices/receipt-printer/print) includes optional orderId plus server-rendered receiptHtml and documentTitle (same HTML as GET /vendor/orders/:id/receipt-html) so vendor-desktop can thermal-print without calling the HTTP API from Electron; optional openCashDrawer: true opens the drawer after a successful print. |
| Outcome | Terminal sends payment_result over the socket (paymentReference when the terminal has an acquirer/auth ref, orderReference (order id), outcome); the server calls the same handleTerminalPaymentWebhook path as the HTTP webhook. payment_request may omit paymentReference until a ref exists; correlate by orderReference. Reconnect uses exponential backoff in the app. |
payment_result optional fields (receipt ACCT / CARD / DATE / REF): besides cashierName, registerLabel, terminalLabel, terminalId, cardBrand, authCode, and reason, the bridge may send entryMode (e.g. CHIP, CONTACTLESS), cardLast4, cardExp (e.g. MM/YY), and terminalTransactionAt (ISO-8601). These map into TerminalPaymentWebhookDto as entry_mode, card_last4, card_exp, and terminal_transaction_at, are merged into orders.terminal_payment_meta, and drive the shared receipt “transaction record” block (ACCT combines brand + entry mode; CARD uses last4 + expiry; DATE/TIME prefers terminal time when present; REF uses transaction_ref / paymentReference).
Environment variables and example URLs
Section titled “Environment variables and example URLs”| Variable | Role |
|---|---|
PUBLIC_API_BASE_URL | Optional. Stable public API origin (scheme + host + port, no /api/v1 path) used to construct wsUrl and webhookUrl in the activate response. If omitted, the server infers from the incoming activate request’s Host / X-Forwarded-* headers. |
DEFAULT_POS_CURRENCY | Optional. Default currency code for payment intent amounts when not provided (implementation default USD). |
vendor-web persists wsUrl, LAN host/port, webhookUrl, and related fields in localStorage after activation for the bridge agent and lab pages (vendor.posBridgeAgent.*.v1 plus nipos.vendor.posBridge.deviceSnapshots.v1 per device for multi-terminal desktop auto-start); the bridge socket does not use VITE_API_URL. Removing a device or stopping from Settings uses the session key parsed from that row’s saved wsUrl (store:<storeId> or terminal:<store_devices.id>) so the correct vendor-desktop agent is stopped. The dedicated POS bridge agent debug page may still call stop all explicitly. See Frontend applications and Environment configuration.
Local development examples (Nest PORT=3000, route prefix api/v1):
| Client | Typical API base for the app |
|---|---|
| Same machine / iOS Simulator | http://localhost:3000 |
| Android emulator | http://10.0.2.2:3000 |
| Physical device on LAN | http://<your-computer-LAN-IP>:3000 |
- Activate:
{apiBase}/api/v1/pos-bridge/activate - WebSocket path:
{apiBase}with schemewsorwss→/api/v1/pos-bridge/ws?storeId=…&token=<store bridge key>(preferred) or legacy?terminalId=<store_devices.id>&token=<device api_key> - HTTP webhook (if used):
{apiBase}/api/v1/pos/payments/terminal-webhook+x-pos-terminal-key
Webhook status values (string enum): succeeded, failed, cancelled. At least one of order_id or transaction_ref must be present so the service can resolve the order.
On success, the order is marked paid and delivered. On refunded, payment is unpaid and the order is treated as returned (refund path). On cancelled after a paid capture in a terminal-fulfillment status (e.g. delivered/completed — post-capture void), payment is unpaid, the order is cancelled (not returned), and the card transaction row uses payment_cancelled. Other failure/cancel paths follow existing PosService logic (e.g. unpaid + payment_failed when cancel applies before that fulfillment window).
See also: POS and payment webhooks.
ECR Hub package — @indochina/ecr-hub
Section titled “ECR Hub package — @indochina/ecr-hub”Card-terminal logic now lives in a dedicated workspace package — packages/ecr-hub — so admin / vendor / desktop / backend code can integrate with PAX and CodePay through a single contract and we can add new manufacturers without touching call-sites.
Layering
Section titled “Layering”The package is split into three small layers; each layer has one responsibility:
flowchart LR POS["POS caller<br/>(vendor-web / desktop / backend)"] Hub["EcrHub<br/>orchestrator<br/>(packages/ecr-hub/orchestrator)"] PaxAdapter["PaxAdapter<br/>(packages/ecr-hub/manufacturers/pax)"] CodepayAdapter["CodepayAdapter<br/>(packages/ecr-hub/manufacturers/codepay)"] PaxTransport["PAX TCP transport<br/>(electron / native)"] CodepayTransport["CodePay WebSocket transport<br/>(pos-bridge socket)"] POS -->|"EcrHubCommand"| Hub Hub -->|encode| PaxAdapter Hub -->|encode| CodepayAdapter PaxAdapter -->|"PAX framed string"| PaxTransport CodepayAdapter -->|"JSON envelope"| CodepayTransport PaxTransport --> PaxAdapter CodepayTransport --> CodepayAdapter PaxAdapter -->|decode| Hub CodepayAdapter -->|decode| Hub Hub -->|"EcrHubResult"| POS
| Layer | Responsibility | Key types |
|---|---|---|
Orchestrator (@indochina/ecr-hub/orchestrator) | Routes a canonical command to the right manufacturer adapter + transport, generates correlationId if missing, clamps timeouts, emits structured logs. Knows nothing about TCP vs WebSocket. | EcrHub, createEcrHub, EcrHubOptions, EcrHubAdapterRegistration |
Manufacturer adapter (@indochina/ecr-hub/pax, @indochina/ecr-hub/codepay) | Encodes EcrHubCommand into manufacturer wire format, decodes raw response into EcrHubResult, classifies transport failures (timeout / aborted / transport_error). | PaxAdapter, paxAdapter, CodepayAdapter, plus framing/parser helpers (buildPaxT00Payload, parsePaxTerminalResponse, encodeCodepaySale, parseCodepayResponse, …) |
| Transport (app-owned) | Implements EcrTransport. Sends a string payload + correlationId and resolves with the matching response. Owns connect / abort / heartbeat. | EcrTransport, EcrTransportResult |
The hub never opens a socket or parses bytes; it only orchestrates. Apps own the transport because connection lifecycle differs (Electron IPC, browser WebSocket, Node TCP, etc.).
Bridge relay (server, ECR Hub, POS bridge, vendor-desktop, terminal)
Section titled “Bridge relay (server, ECR Hub, POS bridge, vendor-desktop, terminal)”When a payment_terminal row is paired and vendor-desktop holds the POS bridge WebSocket, the backend can dispatch EcrHubCommand through a thin transport that does not open TCP/WS itself. Instead, createBridgeRelayTransport (Nest EcrHubModule, apps/backend/src/modules/ecr-hub/bridge-relay-transport.ts) closes over deviceId, manufacturer (ECR_MFR from @indochina/ecr-hub — not legacy "PAX" / "CODEPAY" strings), relayTransportKind (tcp for PAX LAN, ws for CodePay service WebSocket), and relayTarget (host/port or wsUrl + appId). Each EcrTransport.send becomes PosBridgeService.sendAndWait, which emits ecr_relay_request on the bridge socket and waits for ecr_relay_response with the same correlationId.
Wire encoding (bridge only): PAX TCP payloads are base64 on the JSON socket (adapter still speaks latin1 framed bytes); CodePay payloads are UTF-8 JSON strings forwarded verbatim. Decoding mirrors that in createBridgeRelayTransport so PaxAdapter / CodepayAdapter stay unaware of the bridge.
Manufacturer (ECR_MFR) | relayTransportKind | Physical hop | BridgeRelayRequest.payload on the socket |
|---|---|---|---|
PAX | tcp | Desktop → terminal TCP (semi-integrated framing) | base64 of latin1 adapter bytes |
CODEPAY | ws | Desktop → CodePay WebSocket | JSON string as produced by CodepayAdapter |
sequenceDiagram participant BE as Nest API (PosService / EcrHubService) participant Hub as EcrHub + adapter participant Tr as createBridgeRelayTransport participant PB as PosBridgeService participant WS as POS bridge WebSocket participant VD as vendor-desktop agent participant Term as Physical terminal / CodePay WS BE->>Hub: dispatch(EcrHubCommand) Hub->>Tr: transport.send(encoded payload, correlationId) Tr->>PB: sendAndWait(deviceId, manufacturer, relayTransport, target, payload…) PB->>WS: ecr_relay_request WS->>VD: JSON envelope VD->>Term: TCP or outbound WS Term-->>VD: raw bytes / JSON VD->>WS: ecr_relay_response (same correlationId) WS->>PB: settle pending relay PB-->>Tr: ok + rawResponse Tr-->>Hub: decode to adapter form Hub-->>BE: EcrHubResult
POS sale persistence (ECR relay path): When PosService drives a card intent through EcrHubService.dispatch (vendor-desktop returns ecr_relay_response and the hub decodes EcrHubResult), the backend maps result.normalized into TerminalPaymentWebhookDto and runs the same handleTerminalPaymentWebhook path as the legacy bridge message payment_result. The desktop does not send a second payment_result for that flow; order and order_payments rows update on the server after dispatch settles.
For CodePay-only lab and topic reference, see POS card terminal — CodePay (ECR Hub).
Canonical actions
Section titled “Canonical actions”EcrAction is the manufacturer-agnostic verb POS callers use. Adapters declare which subset they support via adapter.supports(action):
| Action | PAX | CodePay | Purpose |
|---|---|---|---|
SALE | ✅ | ✅ | Authorize a card sale for amountCents |
RETURN | ✅ | ✅ | Refund all or part of an existing sale (reference = original) |
VOID_SALE | ✅ | ✅ | Void an unsettled sale by reference |
VOID_RETURN | ✅ | ✅ | Void an unsettled return by reference |
TIP_ADJUSTMENT | ✅ | ✅ | Add or update a tip on an authorized sale |
BATCH_CLOSE | ✅ | ✅ | Settle the current batch on the terminal |
REPORT | ✅ | ✅ | Query a transaction or batch report |
PRINTER | ✅ | ✅ | Reprint last receipt (CodePay) / send raw print payload (PAX) |
SIGN | ✅ | — | Capture customer signature image (PAX-only today) |
ABORT | ✅ | — | Cancel the in-flight transaction on the terminal |
Canonical command and result
Section titled “Canonical command and result”EcrHubCommand and EcrHubResult are defined in @indochina/ecr-hub/types and are the single contract every caller writes against. EcrHubResult.normalized is intentionally aligned with PaymentTerminalDispatchResult and TerminalPaymentWebhookDto so the same shape can drive desktop UI, vendor-web logging, and the backend webhook without re-mapping fields.
import { createEcrHub, paxAdapter, CodepayAdapter, type EcrHubCommand, type EcrTransport,} from "@indochina/ecr-hub";
const hub = createEcrHub({ defaultManufacturer: "pax", maxTimeoutMs: 120_000 }) .register("pax", { adapter: paxAdapter, transport: paxTcpTransport }) .register("codepay", { adapter: new CodepayAdapter({ appId: import.meta.env.VITE_CODEPAY_APP_ID }), transport: codepayWebSocketTransport, });
const command: EcrHubCommand = { action: "SALE", correlationId: orderId, // or omit, hub will generate amountCents: 12_99, reference: orderId, clerkId: cashierId, metadata: { onScreenTip: true, onScreenSignature: true },};
const result = await hub.dispatch({ ...command, manufacturer: "codepay" });if (!result.ok) { // result.status: 'declined' | 'timeout' | 'transport_error' | … return;}const normalized = result.normalized; // feed into TerminalPaymentWebhookDtoSubpath imports
Section titled “Subpath imports”The package exposes typed entrypoints so callers only pull the pieces they use:
| Import | Use for |
|---|---|
@indochina/ecr-hub | Re-exports everything (types + adapters + orchestrator) |
@indochina/ecr-hub/types | Canonical types only — safe to import in shared code that just consumes results |
@indochina/ecr-hub/pax | PaxAdapter, framing constants, buildPaxT00Payload, parsePaxTerminalResponse |
@indochina/ecr-hub/codepay | CodepayAdapter, CODEPAY_ECR_HUB_* topic constants, encoder/parser helpers |
@indochina/ecr-hub/orchestrator | EcrHub, createEcrHub |
Backwards-compat: the previous
@indochina/shared/codepay/codepay-ecr-hub.constantspath still works. It re-exports from@indochina/ecr-hub/codepayso existing call-sites invendor-webandbackendcontinue to compile during the migration. New code should import from@indochina/ecr-hubdirectly.
How current call-sites map
Section titled “How current call-sites map”| Call-site | Today | After migration |
|---|---|---|
apps/vendor-desktop PAX TCP bridge | Hand-rolls PAX framing in pax-bridge-pax.ts | Use paxAdapter + a thin EcrTransport wrapping the existing TCP socket |
apps/vendor-web terminal lab (CodePay) | Builds JSON envelopes inline using CODEPAY_ECR_HUB_* constants | Use CodepayAdapter + a EcrTransport over the pos-bridge WebSocket |
apps/backend payment terminal adapter | Bespoke PaymentTerminalDispatchResult builders per integration | Consume EcrHubResult.normalized (already shaped to match) |
Adapters are intentionally pure (no transport, no DOM, no Node-only APIs) so the same code runs in vendor-web, vendor-desktop, and backend.
CodePay integration (overview)
Section titled “CodePay integration (overview)”- Transport: JSON ECR Hub envelopes over the same POS bridge WebSocket as other lab tooling (
ecrhub.pay.order,ecrhub.pay.tip.adjustment, batch close, query, reprint). - Package:
CodepayAdapterand helpers live inpackages/ecr-hub(@indochina/ecr-hub/codepay); constants match CodePay’s same-terminal integration demo. - Configuration: Vendor → Settings → Devices — manufacturer CodePay, app id required for
encode(). - Developer UI:
/dashboard/settings/codepay-wsin vendor-web; sample payloads inapps/vendor-web/src/lib/codepay-ws-lab-samples.ts.
Full reference: POS card terminal — CodePay (ECR Hub).
Target architecture with PAX A920
Section titled “Target architecture with PAX A920”PAX A920 is an Android smart terminal. Integration options depend on your acquirer, PAX profile, and whether you use PAX semi-integrated APIs, cloud push, or a payment gateway that already speaks to PAX. Regardless of the path, the platform boundary for LionPOS stays the same:
flowchart LR
subgraph store["Store LAN / counter"]
A920["PAX A920\n(card + PIN)"]
Bridge["Bridge service\n(PAX SDK / gateway / middleware)"]
end
subgraph cloud["LionPOS backend"]
API["HTTPS\n/api/v1/pos/payments/terminal-webhook"]
DB[(Orders + store_devices)]
end
subgraph browser["Vendor POS SPA"]
UI["Poll payment-status"]
end
A920 --> Bridge
Bridge -->|"x-pos-terminal-key + JSON body"| API
API --> DB
UI -->|"JWT"| API
Recommended layering
Section titled “Recommended layering”- Device layer (PAX / CodePay) — EMV, PIN, receipts; produces transaction lifecycle events on the wire.
- ECR Hub adapter layer (
@indochina/ecr-hub) — Encodes canonicalEcrHubCommandinto the manufacturer wire format; decodes responses intoEcrHubResult.normalized(already shaped to matchTerminalPaymentWebhookDto). - Bridge layer (new or third-party) — Owns the transport (
EcrTransport) that ferries payloads between the adapter and the device, plus retries / signing / queueing for the store network. For PAX A920 this is typically a small always-on service; for CodePay it is the existing pos-bridge WebSocket. - LionPOS API — Validates
x-pos-terminal-key, resolves order, updates payment state idempotently where possible. - Vendor POS — Already polls; optional UX improvements (timeouts, manual retry, support copy).
Why a bridge is usually required
Section titled “Why a bridge is usually required”The backend expects a simple HTTPS JSON POST. PAX stacks often expose device-local APIs, proprietary message formats, or acquirer-specific hosts. A small always-on service at the store (or a PCI-scoped gateway in the cloud) is the usual place to convert those into our webhook.
Sequence: happy path
Section titled “Sequence: happy path”sequenceDiagram participant Staff as Vendor staff (browser) participant BE as LionPOS API participant Term as PAX + bridge Staff->>BE: POST payment-intent (optional transaction_ref) BE-->>Staff: pending_terminal_payment Staff->>BE: GET payment-status (poll) Note over Staff,Term: Customer taps/inserts card on A920 Term->>BE: POST terminal-webhook (succeeded + refs) BE-->>Term: 200 OK Staff->>BE: GET payment-status BE-->>Staff: paid + delivered
Security and operations
Section titled “Security and operations”| Topic | Guidance |
|---|---|
| Terminal key | Treat x-pos-terminal-key like a shared secret per store device row; rotate on compromise; never log full key in application logs. |
| Transport | TLS 1.2+ only; avoid exposing webhook URL over plain HTTP in production. |
| Identification | Prefer order_id from our system plus a terminal transaction_ref that matches what was set at intent time when possible. |
| Idempotency | The bridge may retry; backend should tolerate duplicate succeeded callbacks for the same order_id / transaction_ref (verify current PosService behavior and add guards if duplicates cause inconsistent state). |
| PCI | Card data must not pass through LionPOS APIs described here; keep PAN/key entry on the certified terminal path. |
Gap analysis: PAX-specific work
Section titled “Gap analysis: PAX-specific work”Concrete steps depend on your signed PAX integration pack. Track these as explicit tasks:
- Select integration mode — semi-integrated (LAN/USB) vs cloud callback vs gateway-hosted.
- Map events — PAX “approved/declined/timeout/cancel” →
succeeded/failed/cancelled. - Map identifiers — Ensure
transaction_refin webhook matches order field used for reconciliation (and EMV receipt if required). - Implement bridge — Service that holds PAX credentials and calls our webhook with
x-pos-terminal-key. Use@indochina/ecr-hub/pax(paxAdapter+buildPaxT00Payload+parsePaxTerminalResponse) for encoding / decoding so the bridge only owns the TCP transport. - Provisioning — How each store gets
payment_terminalstore_devicesrow + API key in DB/admin UI. - Observability — Structured logs on bridge + correlation id (
order_id,transaction_ref). - Failure modes — Offline terminal, duplicate webhook, late callback after staff cancelled in UI.
Implementation checklist (engineering)
Section titled “Implementation checklist (engineering)”Use this as a living task list for the feature epic.
Discovery
Section titled “Discovery”- Confirm acquirer and PAX software stack (Semi-Integration, PayDroid version, remote download keys).
- Obtain sandbox terminals or simulator credentials.
- Document exact hostnames, ports, and message formats from PAX/gateway docs under NDA.
Bridge / adapter
Section titled “Bridge / adapter”- Wire the bridge against
@indochina/ecr-hub— registerpaxAdapter(and/orCodepayAdapter) with an app-ownedEcrTransport; consumeEcrHubResult.normalizedto build the webhook body. - Implement process that receives terminal outcome and builds
POSTto/api/v1/pos/payments/terminal-webhook. - Configure one API key per store (or per device if you split rows) in
store_devices. - Add retry with backoff for 5xx from LionPOS; dead-letter queue for manual reconciliation.
Backend (only if contract gaps appear)
Section titled “Backend (only if contract gaps appear)”- Optional: HMAC or signature header in addition to
x-pos-terminal-keyif required by security review. - Optional: IP allowlist for known bridge egress (infrastructure-dependent).
- Harden idempotent handling for duplicate
succeededwebhooks (if not already). - Unit tests (
*.spec.ts) for any new branches per project rules.
Frontend (vendor POS)
Section titled “Frontend (vendor POS)”- Confirm polling interval/timeouts match expected terminal latency.
- UX for declined, cancelled, and stuck pending (operator actions).
- i18n for all new user-visible strings (
en+vi).
Documentation
Section titled “Documentation”- Keep this page updated when the webhook contract or device provisioning changes.
- Link runbooks from Operations & troubleshooting if incidents repeat.
Test scenarios (UAT / QA)
Section titled “Test scenarios (UAT / QA)”| ID | Scenario | Steps | Expected |
|---|---|---|---|
| T1 | Happy path card approval | Card order → payment-intent → terminal approves → webhook succeeded | Order paid, status delivered, UI shows success |
| T2 | Declined card | Force decline on terminal or test card | Webhook failed → order payment failed, UI shows failure |
| T3 | Customer cancels on terminal | Cancel from device | Webhook cancelled → order unpaid / cancelled flow per product rules |
| T4 | Missing API key | POST webhook without x-pos-terminal-key | 401 Unauthorized |
| T5 | Wrong API key | Header does not match store device | 401 invalid key (or 503 if no terminal configured — see service behavior) |
| T6 | Unknown order | Webhook with bogus order_id / transaction_ref | 400 order not found |
| T7 | Duplicate success webhook | Send succeeded twice with same refs | Second request should not corrupt data (verify after hardening) |
| T8 | Polling before webhook | UI polls while terminal still processing | Stays pending until webhook |
| T9 | Network loss at bridge | Webhook delayed; later retry succeeds | Order eventually consistent; staff messaging clear |
Automate what you can: extend pos.service.spec.ts patterns for new backend branches; use integration tests for the webhook controller.
Related
Section titled “Related”- POS card terminal — CodePay (ECR Hub) — CodePay topics, envelope, adapter, lab
- POS and payment webhooks — API summary
- Vendor POS — operator-facing flow
Document history
Section titled “Document history”| Version | Date | Notes |
|---|---|---|
| 1.0 | 2026-04-10 | Initial architecture + checklist for PAX-style terminal integration |
| 1.1 | 2026-04-26 | Introduce @indochina/ecr-hub package (canonical actions + PAX/CodePay adapters + orchestrator); update layering, gap analysis, and bridge checklist to reuse the hub |
| 1.2 | 2026-04-26 | apps/pax-pos-mobile removed from the monorepo; POS bridge WebSocket section reframed around vendor-web / vendor-desktop clients |
| 1.3 | 2026-04-26 | Title broadened to ECR Hub (PAX focus); CodePay overview + link to dedicated page pos-terminal-codepay-integration; sidebar order 10 |
| 1.4 | 2026-04-27 | Document that PosService applies handleTerminalPaymentWebhook after EcrHubResult resolves on the ECR relay path (no separate payment_result from vendor-desktop) |