Skip to main content

SYNC_CONTRACT — billing-service

Conforms to ADR-0003 Electron Offline-First Desktop. The Electron desktop runs the front-desk and back-office workflows; staff must be able to add charges and accept cash through multi-hour blackouts. This contract specifies which billing-service aggregates replicate to the desktop, the conflict resolution policy per field, and which operations are online-only (because they require live cryptographic verification or affect external systems).

1. Replication scope

AggregateReplicated to desktop?DirectionFilter
FolioYesbidirectionalFolios for the property's active stays (status ∈ {open,balance_due,re_opened} or reservation.checkOutDate ≥ today − 1d)
FolioChargeYesbidirectionalchild of replicated folio
FolioPaymentYesbidirectionalchild of replicated folio
FolioRefundYesbidirectionalchild of replicated folio
InvoiceYes (read-only)server → desktopfor replicated folios; desktop renders thumbnail + downloads PDF lazily
CreditNoteYes (read-only)server → desktopfor replicated folios
CashDrawerYesserver → desktop on register, no upstream mutationone per local register
CashDrawerSessionYes (constrained)bidirectional with online-only closesessions for the local drawer
SettlementYes (read-only)server → desktopfor closed folios
Subscription / SubscriptionInvoice / UsageRecord / PlanNoserver-onlyplatform-admin scope; no desktop need

The desktop does not replicate the per-tenant _outbox or _inbox; it has its own local outbox (local_outbox) and inbox (local_inbox) under SQLCipher.

2. Local schema (Electron, SQLCipher)

CREATE TABLE folios_local (
id text PRIMARY KEY,
tenant_id text NOT NULL,
property_id text NOT NULL,
reservation_id text NOT NULL,
currency char(3) NOT NULL,
fx_snapshot jsonb NOT NULL,
status text NOT NULL,
opened_at timestamptz NOT NULL,
closed_at timestamptz,
server_version int NOT NULL DEFAULT 0,
local_dirty int NOT NULL DEFAULT 0,
last_synced_at timestamptz
);
CREATE TABLE folio_charges_local (...);
CREATE TABLE folio_payments_local (...);
CREATE TABLE folio_refunds_local (...);
CREATE TABLE invoices_local (...);
CREATE TABLE cash_drawer_sessions_local (...);
CREATE TABLE local_outbox (
id text PRIMARY KEY, op text, payload jsonb, created_at timestamptz, attempts int DEFAULT 0
);
CREATE TABLE local_inbox (key text PRIMARY KEY, result jsonb, created_at timestamptz);

3. Pull (server → desktop)

The desktop's sync.engine opens a long-poll subscription to melmastoon.billing.folio.* and melmastoon.billing.cash_drawer.* filtered by tenantid and the desktop's bound propertyId. On reconnect after offline, it issues:

GET /api/v1/sync/billing/state?since=<lsn>&propertyId=prop_…

Response shape:

{
"folios": [ /* full snapshot of replicated folios with version */ ],
"charges": [...], "payments": [...], "refunds": [...],
"invoices": [...], "creditNotes": [...],
"cashSessions": [...],
"lsn": "00010000000000000B14"
}

The desktop applies pulled rows under the per-aggregate conflict policy described below.

4. Push (desktop → server)

Local mutations are written first to the local DB, then enqueued in local_outbox as one of:

  • folio.charge.add
  • folio.payment.cash.record
  • folio.refund.cash.record
  • cash_session.open
  • cash_session.initiate_close

The drainer dispatches each to POST /api/v1/folios/:id/charges|payments|refunds or POST /api/v1/cash-drawers/:id/sessions with:

  • Idempotency-Key = the local row's stable id (e.g., chg_… is generated client-side and reused on retry).
  • If-Match = the last server version we observed (OCC).

On HTTP 412 / 409, the desktop applies the conflict-resolution policy below and re-tries up to 5 times with capped exponential backoff before surfacing a manual-resolution UI.

5. Conflict-resolution policy per aggregate / field

Aggregate / fieldPolicyNotes
Folio.statusserver_authoritativeserver is the only authority on transitions; client transitions are advisory and rolled back on conflict
Folio.balance (computed)n/a (computed)always recomputed from rows
Folio.fx_snapshotserver_authoritativethe snapshot is pinned upstream; clients never mutate
FolioCharge rowsappend_only with client-stable idboth sides may insert; identical chg_ ids dedupe (idempotent); server is authoritative on tax.amount_micro (recomputed on receipt)
FolioCharge.tax.*server_authoritativeserver overwrites client tax on receipt because per-tenant tax rules can change
FolioPayment rows (cash)append_only with client-stable iddesktop is the source of truth for cashSessionId, recordedBy, metadata; server overwrites only amount_micro if it disagrees with the session ledger
FolioPayment.amount_microserver_authoritative on disagreementthe server is canonical; desktop reconciles its session row when the server sends back the corrected payment
FolioRefund rowsappend_only with client-stable idsame rules as cash payment
Invoice rowsserver_authoritative (read-only on desktop)issuance happens server-side at close
CreditNote rowsserver_authoritative (read-only on desktop)desktop never writes
CashDrawerSession.openingFloatclient wins on first push then server_authoritativeonce persisted server-side, immutable
CashDrawerSession.receipts (logical via folio_payments)append_onlyas cash payments
CashDrawerSession.countedClosingFloatclient wins within the close handshakecounted by staff; server only validates against expectedClosingFloat
CashDrawerSession.statusserver_authoritativeopen → pending_close may push offline; pending_close → closed is online-only (see §6)
Settlementserver_authoritativecomputed at close

max-of is not used in this service; financial state must converge on a single ground truth, not an optimistic max.

6. Online-only operations

The following operations refuse offline execution and surface "requires connectivity" UX:

  1. POST /cash-sessions/:id/close — requires fresh iam-service step-up auth for the co-signer (TOTP/WebAuthn) and immediate outbox publish for the audit chain. The desktop allows initiate-close offline (counted closing float captured), but the finalization is held in cash_session.pending_close.local_resolved=false until connectivity returns. On reconnect, the local action is replayed against the server.
  2. POST /folios/:id/refunds with method != cash (i.e., reversing an external paymentId) — requires payment-gateway-service to execute the refund.
  3. POST /folios/:id/close — required when issuing the invoice; tax recompute and PDF render run server-side.
  4. POST /invoices/:id/credit-notes — server-side numbering and PDF render.
  5. POST /folios/:id/reopen — supervisor override audit chain; online-only.
  6. POST /cash-sessions/:id/acknowledge-discrepancy — supervisor co-sign; online-only.

The desktop UI always shows the connectivity badge and the "queued cash drawer close" pending state when offline.

7. Sync handshake & versioning

Each replicated row carries a server_version integer. The pull response includes the row's current version; the desktop overwrites local row only if pulled.version > local.server_version. On push, the request carries If-Match: <server_version>; server returns 412 PRECONDITION_FAILED on mismatch and the desktop re-pulls then retries.

For append-only collections (charges, payments, refunds), the desktop assigns the stable chg_/fpm_/frd_ ULID locally; the server treats duplicate inserts as no-ops and returns the canonical row. This makes retry safe under partial network loss.

8. Replication lag SLOs

PathTarget
Cloud → desktop event apply (online)p95 ≤ 3 s
Desktop → cloud charge push (online)p95 ≤ 2 s
Cold reconnect (8 h offline) full pullp95 ≤ 45 s for ≤ 1,000 active folios + 50 sessions
Local-only operation (offline charge / cash receipt)UI ack ≤ 150 ms

9. Failure & manual-resolution UX

Conflicts that the automatic policy cannot resolve fall into the Reconcile queue surfaced in the desktop "Sync Inspector":

ScenarioDesktop UX
OCC failure on charge push after 5 retriesYellow banner on folio: "Another change happened — refresh and re-apply". Local row marked pending_review.
Cash payment server amount_micro disagreementRed banner on session: "Server adjusted a cash receipt by N AFN — review session reconciliation." Folio + session re-pulled.
Cash drawer close request while offlinePending close badge + "Reconnect to finalize close" CTA.
Tax recompute on charge changed materially after syncBanner: "Tax was recomputed by the server" with diff.

10. Data minimization on desktop

  • The desktop does not receive any subscription billing data, any other tenant's data, any cardholder data, or any payment-method tokens.
  • folio_payments_local carries external_payment_id opaquely (no PAN, no token).
  • The local DB is encrypted with SQLCipher AES-256; the key is derived from a hardware-bound secret per 07 Security §6.3.
  • On user logout the local DB is wiped for the property's data; the encryption key is rotated.

11. Cross-references