Bỏ qua để đến nội dung

POS card terminals & ECR Hub (PAX focus)

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

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 by vendor-desktop and the terminal lab).
  • CodePay (WebSocket) — JSON envelopes with ecrhub.* topics over the bridge WebSocket (used by vendor-web terminal 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).


GoalsNon-goals (for this design doc)
End-to-end card flow: order → terminal → webhook → paid/failed UIChoosing a single acquirer or PAX reseller
Clear ownership: terminal bridge vs backend vs vendor POSFull PCI DSS audit write-up (reference your QSA)
Idempotent, auditable callbacksShipping PAX proprietary SDK code in this monorepo without a vendor contract

Current platform behavior (reference implementation)

Section titled “Current platform behavior (reference implementation)”
  • 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_request on 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 via payment_result on the socket or the HTTP terminal webhook. POST /pos/orders returns immediately with payment.required / pending status and a digits-only payment.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-status until payment_status reflects a terminal outcome (or the operator cancels).
  • Retry after decline: if the last attempt left order_status: payment_failed (e.g. TCP SALE declined), calling POST .../payment-intent again resets order_status to awaiting_confirmation and clears the related order_transactions.status marker so the client response and payment-status polling are not permanently stuck on failure.
ConcernRoute / behavior
Intent + pollingPOST /api/v1/pos/orders/:orderId/payment-intent, GET /api/v1/pos/orders/:orderId/payment-status
Terminal callbackPOST /api/v1/pos/payments/terminal-webhook — body: TerminalPaymentWebhookDto; header: x-pos-terminal-key
Auth modelWebhook 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.

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:

StepAPI / 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/activatevendor 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-batchvendor 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-timeWebSocket 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.
OutcomeTerminal 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).

VariableRole
PUBLIC_API_BASE_URLOptional. 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_CURRENCYOptional. 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):

ClientTypical API base for the app
Same machine / iOS Simulatorhttp://localhost:3000
Android emulatorhttp://10.0.2.2:3000
Physical device on LANhttp://<your-computer-LAN-IP>:3000
  • Activate: {apiBase}/api/v1/pos-bridge/activate
  • WebSocket path: {apiBase} with scheme ws or wss/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.


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.

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
LayerResponsibilityKey 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)relayTransportKindPhysical hopBridgeRelayRequest.payload on the socket
PAXtcpDesktop → terminal TCP (semi-integrated framing)base64 of latin1 adapter bytes
CODEPAYwsDesktop → CodePay WebSocketJSON 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).

EcrAction is the manufacturer-agnostic verb POS callers use. Adapters declare which subset they support via adapter.supports(action):

ActionPAXCodePayPurpose
SALEAuthorize a card sale for amountCents
RETURNRefund all or part of an existing sale (reference = original)
VOID_SALEVoid an unsettled sale by reference
VOID_RETURNVoid an unsettled return by reference
TIP_ADJUSTMENTAdd or update a tip on an authorized sale
BATCH_CLOSESettle the current batch on the terminal
REPORTQuery a transaction or batch report
PRINTERReprint last receipt (CodePay) / send raw print payload (PAX)
SIGNCapture customer signature image (PAX-only today)
ABORTCancel the in-flight transaction on the terminal

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 TerminalPaymentWebhookDto

The package exposes typed entrypoints so callers only pull the pieces they use:

ImportUse for
@indochina/ecr-hubRe-exports everything (types + adapters + orchestrator)
@indochina/ecr-hub/typesCanonical types only — safe to import in shared code that just consumes results
@indochina/ecr-hub/paxPaxAdapter, framing constants, buildPaxT00Payload, parsePaxTerminalResponse
@indochina/ecr-hub/codepayCodepayAdapter, CODEPAY_ECR_HUB_* topic constants, encoder/parser helpers
@indochina/ecr-hub/orchestratorEcrHub, createEcrHub

Backwards-compat: the previous @indochina/shared/codepay/codepay-ecr-hub.constants path still works. It re-exports from @indochina/ecr-hub/codepay so existing call-sites in vendor-web and backend continue to compile during the migration. New code should import from @indochina/ecr-hub directly.

Call-siteTodayAfter migration
apps/vendor-desktop PAX TCP bridgeHand-rolls PAX framing in pax-bridge-pax.tsUse paxAdapter + a thin EcrTransport wrapping the existing TCP socket
apps/vendor-web terminal lab (CodePay)Builds JSON envelopes inline using CODEPAY_ECR_HUB_* constantsUse CodepayAdapter + a EcrTransport over the pos-bridge WebSocket
apps/backend payment terminal adapterBespoke PaymentTerminalDispatchResult builders per integrationConsume 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.

  • 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: CodepayAdapter and helpers live in packages/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-ws in vendor-web; sample payloads in apps/vendor-web/src/lib/codepay-ws-lab-samples.ts.

Full reference: POS card terminal — CodePay (ECR Hub).


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
  1. Device layer (PAX / CodePay) — EMV, PIN, receipts; produces transaction lifecycle events on the wire.
  2. ECR Hub adapter layer (@indochina/ecr-hub) — Encodes canonical EcrHubCommand into the manufacturer wire format; decodes responses into EcrHubResult.normalized (already shaped to match TerminalPaymentWebhookDto).
  3. 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.
  4. LionPOS API — Validates x-pos-terminal-key, resolves order, updates payment state idempotently where possible.
  5. Vendor POS — Already polls; optional UX improvements (timeouts, manual retry, support copy).

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.


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

TopicGuidance
Terminal keyTreat x-pos-terminal-key like a shared secret per store device row; rotate on compromise; never log full key in application logs.
TransportTLS 1.2+ only; avoid exposing webhook URL over plain HTTP in production.
IdentificationPrefer order_id from our system plus a terminal transaction_ref that matches what was set at intent time when possible.
IdempotencyThe 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).
PCICard data must not pass through LionPOS APIs described here; keep PAN/key entry on the certified terminal path.

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_ref in 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_terminal store_devices row + 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.

Use this as a living task list for the feature epic.

  • 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.
  • Wire the bridge against @indochina/ecr-hub — register paxAdapter (and/or CodepayAdapter) with an app-owned EcrTransport; consume EcrHubResult.normalized to build the webhook body.
  • Implement process that receives terminal outcome and builds POST to /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.
  • Optional: HMAC or signature header in addition to x-pos-terminal-key if required by security review.
  • Optional: IP allowlist for known bridge egress (infrastructure-dependent).
  • Harden idempotent handling for duplicate succeeded webhooks (if not already).
  • Unit tests (*.spec.ts) for any new branches per project rules.
  • 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).
  • Keep this page updated when the webhook contract or device provisioning changes.
  • Link runbooks from Operations & troubleshooting if incidents repeat.

IDScenarioStepsExpected
T1Happy path card approvalCard order → payment-intent → terminal approves → webhook succeededOrder paid, status delivered, UI shows success
T2Declined cardForce decline on terminal or test cardWebhook failed → order payment failed, UI shows failure
T3Customer cancels on terminalCancel from deviceWebhook cancelled → order unpaid / cancelled flow per product rules
T4Missing API keyPOST webhook without x-pos-terminal-key401 Unauthorized
T5Wrong API keyHeader does not match store device401 invalid key (or 503 if no terminal configured — see service behavior)
T6Unknown orderWebhook with bogus order_id / transaction_ref400 order not found
T7Duplicate success webhookSend succeeded twice with same refsSecond request should not corrupt data (verify after hardening)
T8Polling before webhookUI polls while terminal still processingStays pending until webhook
T9Network loss at bridgeWebhook delayed; later retry succeedsOrder 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.



VersionDateNotes
1.02026-04-10Initial architecture + checklist for PAX-style terminal integration
1.12026-04-26Introduce @indochina/ecr-hub package (canonical actions + PAX/CodePay adapters + orchestrator); update layering, gap analysis, and bridge checklist to reuse the hub
1.22026-04-26apps/pax-pos-mobile removed from the monorepo; POS bridge WebSocket section reframed around vendor-web / vendor-desktop clients
1.32026-04-26Title broadened to ECR Hub (PAX focus); CodePay overview + link to dedicated page pos-terminal-codepay-integration; sidebar order 10
1.42026-04-27Document that PosService applies handleTerminalPaymentWebhook after EcrHubResult resolves on the ECR relay path (no separate payment_result from vendor-desktop)