SYNC_CONTRACT — payment-gateway-service
Sibling: API_CONTRACTS · DATA_MODEL · SECURITY_MODEL
Strategic anchors: ADR-0003 Electron Offline-First · 10 Payments Architecture
The Electron desktop backoffice (front desk, night audit, manager) participates with this service in a narrow, deliberately limited sync contract. Two principles dominate:
- No card data ever lives on a desktop, encrypted or otherwise. Tokenization always uses the processor's hosted UI rendered inside the desktop's secure WebView; the resulting
processorTokenis sent directly to the cloud service via REST, never persisted locally. - Cash-on-arrival is offline-first, because front-desk staff at remote properties may operate disconnected for hours. Cash receipts and refunds queue locally, push on reconnect, and are server-authoritative on conflict.
1. Sync surface (what flows where)
| Direction | Resource | Mode | Authority on conflict |
|---|---|---|---|
| Cloud → Desktop | transactions (last 30 days, this property) | Pull, delta by updated_at | Server |
| Cloud → Desktop | payment_methods display metadata only (last4, brand) for active guests | Pull, delta | Server |
| Cloud → Desktop | reconciliations for current shift | Pull on demand | Server |
| Cloud → Desktop | adapter_health_log summary | Pull | Server |
| Desktop → Cloud | cash_receipts (created offline) | Push, idempotent | Server (after merge) |
| Desktop → Cloud | cash_refunds (with dual sign-off) | Push, idempotent | Server |
| Never synced | processor_token_enc, full webhook envelopes, chargebacks, raw card data | — | — |
2. Local desktop schema (SQLite)
Desktop holds a strict subset of the cloud DATA_MODEL — only what front-desk operators need offline:
-- Local read cache (mirrored, server-authoritative)
CREATE TABLE local_transactions (
id TEXT PRIMARY KEY,
reservation_id TEXT NOT NULL,
amount_micro INTEGER NOT NULL,
currency TEXT NOT NULL,
method TEXT NOT NULL,
status TEXT NOT NULL,
processor TEXT NOT NULL,
display_pm TEXT, -- redacted JSON: {brand,last4} only
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
server_version INTEGER NOT NULL,
pulled_at TEXT NOT NULL
);
CREATE INDEX ON local_transactions (reservation_id);
CREATE INDEX ON local_transactions (updated_at);
-- Local write queue (push to cloud, then delete on ack)
CREATE TABLE local_cash_outbox (
id TEXT PRIMARY KEY, -- ULID, becomes Idempotency-Key
kind TEXT NOT NULL, -- 'cash_receipt' | 'cash_refund'
payload TEXT NOT NULL, -- canonical JSON
payload_hash BLOB NOT NULL, -- sha256 over payload
device_id TEXT NOT NULL,
operator_id TEXT NOT NULL,
shift_id TEXT NOT NULL,
created_at TEXT NOT NULL,
attempted_at TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending'|'in_flight'|'acked'|'rejected'|'dlq'
last_error_code TEXT,
acked_server_id TEXT -- pay_<ULID> after success
);
CREATE INDEX ON local_cash_outbox (status, created_at);
Guarantees: SQLite file is encrypted at rest with SQLCipher using a per-device key sealed in the OS keychain (Windows Credential Manager / macOS Keychain). The schema includes no token columns at all — there's nothing to leak even if the file is exfiltrated.
3. Pull protocol (cloud → desktop)
GET /api/v1/payments/sync/transactions?propertyId=ppt_…&since=<iso>&limit=500&cursor=…
{
"items": [
{
"id": "pay_01HZX…", "reservationId": "rsv_01H…", "amount": {…},
"method": "card", "status": "captured", "processor": "stripe",
"display": { "brand": "visa", "last4": "4242" },
"createdAt": "…", "updatedAt": "…", "version": 3
}
],
"nextCursor": "eyJ1cGRhdGVkQXRfXyI6IjIwMjYtMDQtMjJUMTg6MzAifQ==",
"watermark": "2026-04-22T18:31:42.815Z"
}
Desktop persists watermark and uses it as since next pull. Pull is read-only; if version for a row decreases (server rollback) the desktop accepts the older value (server is authoritative). Sync runs every 60s while online and on each reconnect.
4. Push protocol (desktop → cloud)
The desktop drains local_cash_outbox in FIFO order with at-most-one in-flight request per shift:
POST /api/v1/payments/cash/receipts
Idempotency-Key: <local_cash_outbox.id>
X-Tenant-Id: tnt_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
X-Offline-Captured-At: 2026-04-22T18:35:00Z
| Outcome | Local action |
|---|---|
201 Created | Mark acked, store acked_server_id, delete after 7 days |
409 IDEMPOTENCY_KEY_REUSED (matching body) | Treat as success; fetch server paymentId and mark acked |
409 IDEMPOTENCY_KEY_REUSED (different body) | Move to dlq; surface to operator with both bodies for manual reconciliation |
422 RESERVATION_NOT_FOUND | Move to dlq; alert operator (likely tenant-deleted reservation) |
422 CASH_DRAWER_NOT_OPEN | Hold; retry on next shift open event |
5xx / network | Retry with jittered backoff: 5 s, 30 s, 2 m, 10 m, 1 h, then dlq after 24 h |
Network-failure retries do not create new ULIDs — the outbox row keeps its id so the cloud sees the same Idempotency-Key.
5. Cash drawer reconciliation
End-of-shift, the desktop renders a reconciliation screen by:
- Reading
local_transactionswheremethod='cash_on_arrival'andcreated_atwithin shift bounds. - Calling
GET /api/v1/payments/cash/shift-summary?shiftId=…to fetch the server-authoritative summary. - Comparing the two; any drift is highlighted, and the operator is forced to either:
- Push remaining outbox entries (if drift due to pending uploads), or
- Open a
cash_drawer.discrepancyticket (handled bybilling-service).
The shift cannot close until local_cash_outbox has zero pending/in_flight rows for the shift.
6. Tokenization on desktop
Card capture in the desktop runs the processor's hosted UI inside a sandboxed BrowserWindow (CSP-locked, no Node integration, devtools disabled). The flow is:
Desktop UI → POST /api/v1/payments/payment-methods/sessions (server returns clientSecret)
Desktop opens hosted UI in sandboxed view, passes clientSecret
Hosted UI tokenizes card, returns processorToken to desktop
Desktop → POST /api/v1/payments/payment-methods (with processorToken)
Cloud stores encrypted token; returns pm_<ULID>
Desktop stores ONLY pm_<ULID> + display details in local cache
A unit test enforces: no SQLite column may match /^(card|pan|cvv|cvc|cardnumber|fullnumber|processortoken|secret)$/i, and CI runs a grep over the local schema.
7. Conflict resolution rules
| Conflict | Rule |
|---|---|
| Local cache row vs newer server row | Server wins; local row is overwritten on next pull |
| Outbox push collides with server-side server-initiated cash receipt (operator double-recorded) | 409 IDEMPOTENCY_KEY_REUSED if same body; otherwise both are surfaced and the operator picks one to keep (the other is voided server-side) |
| Outbox push during tenant-suspended state | 403 TENANT_SUSPENDED; held in outbox until tenant re-activated, surfaced to operator after 24 h |
Adapter went open after offline cash recorded | Cash receipts always push (cash never goes through adapter); only card-related futures might be blocked |
8. Security & telemetry
- The desktop emits a
desktop_sync.cash_pushedmetric tagged with outcome to Cloud Monitoring via the centralelectron-telemetry-service. - A failed push older than 4 h triggers a desktop-side toast and a server-side alert (paged to property manager via
notification-service). - Sync logs include
device_id,operator_id,shift_id, never any card data or tokens. PII safe by construction (display blocked by schema CHECK).
9. Versioning
The sync contract version is sent as X-Sync-Contract-Version: 1 on every desktop request; servers reject unsupported versions with 426 Upgrade Required. Desktop auto-update is mandatory when the server bumps the major version.