Skip to main content

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:

  1. 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 processorToken is sent directly to the cloud service via REST, never persisted locally.
  2. 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)

DirectionResourceModeAuthority on conflict
Cloud → Desktoptransactions (last 30 days, this property)Pull, delta by updated_atServer
Cloud → Desktoppayment_methods display metadata only (last4, brand) for active guestsPull, deltaServer
Cloud → Desktopreconciliations for current shiftPull on demandServer
Cloud → Desktopadapter_health_log summaryPullServer
Desktop → Cloudcash_receipts (created offline)Push, idempotentServer (after merge)
Desktop → Cloudcash_refunds (with dual sign-off)Push, idempotentServer
Never syncedprocessor_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
OutcomeLocal action
201 CreatedMark 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_FOUNDMove to dlq; alert operator (likely tenant-deleted reservation)
422 CASH_DRAWER_NOT_OPENHold; retry on next shift open event
5xx / networkRetry 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:

  1. Reading local_transactions where method='cash_on_arrival' and created_at within shift bounds.
  2. Calling GET /api/v1/payments/cash/shift-summary?shiftId=… to fetch the server-authoritative summary.
  3. 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.discrepancy ticket (handled by billing-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

ConflictRule
Local cache row vs newer server rowServer 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 state403 TENANT_SUSPENDED; held in outbox until tenant re-activated, surfaced to operator after 24 h
Adapter went open after offline cash recordedCash 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_pushed metric tagged with outcome to Cloud Monitoring via the central electron-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.