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-serviceaggregates 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
| Aggregate | Replicated to desktop? | Direction | Filter |
|---|---|---|---|
Folio | Yes | bidirectional | Folios for the property's active stays (status ∈ {open,balance_due,re_opened} or reservation.checkOutDate ≥ today − 1d) |
FolioCharge | Yes | bidirectional | child of replicated folio |
FolioPayment | Yes | bidirectional | child of replicated folio |
FolioRefund | Yes | bidirectional | child of replicated folio |
Invoice | Yes (read-only) | server → desktop | for replicated folios; desktop renders thumbnail + downloads PDF lazily |
CreditNote | Yes (read-only) | server → desktop | for replicated folios |
CashDrawer | Yes | server → desktop on register, no upstream mutation | one per local register |
CashDrawerSession | Yes (constrained) | bidirectional with online-only close | sessions for the local drawer |
Settlement | Yes (read-only) | server → desktop | for closed folios |
Subscription / SubscriptionInvoice / UsageRecord / Plan | No | server-only | platform-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.addfolio.payment.cash.recordfolio.refund.cash.recordcash_session.opencash_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 serverversionwe 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 / field | Policy | Notes |
|---|---|---|
Folio.status | server_authoritative | server 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_snapshot | server_authoritative | the snapshot is pinned upstream; clients never mutate |
FolioCharge rows | append_only with client-stable id | both sides may insert; identical chg_ ids dedupe (idempotent); server is authoritative on tax.amount_micro (recomputed on receipt) |
FolioCharge.tax.* | server_authoritative | server overwrites client tax on receipt because per-tenant tax rules can change |
FolioPayment rows (cash) | append_only with client-stable id | desktop is the source of truth for cashSessionId, recordedBy, metadata; server overwrites only amount_micro if it disagrees with the session ledger |
FolioPayment.amount_micro | server_authoritative on disagreement | the server is canonical; desktop reconciles its session row when the server sends back the corrected payment |
FolioRefund rows | append_only with client-stable id | same rules as cash payment |
Invoice rows | server_authoritative (read-only on desktop) | issuance happens server-side at close |
CreditNote rows | server_authoritative (read-only on desktop) | desktop never writes |
CashDrawerSession.openingFloat | client wins on first push then server_authoritative | once persisted server-side, immutable |
CashDrawerSession.receipts (logical via folio_payments) | append_only | as cash payments |
CashDrawerSession.countedClosingFloat | client wins within the close handshake | counted by staff; server only validates against expectedClosingFloat |
CashDrawerSession.status | server_authoritative | open → pending_close may push offline; pending_close → closed is online-only (see §6) |
Settlement | server_authoritative | computed 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:
POST /cash-sessions/:id/close— requires freshiam-servicestep-up auth for the co-signer (TOTP/WebAuthn) and immediate outbox publish for the audit chain. The desktop allowsinitiate-closeoffline (counted closing float captured), but the finalization is held incash_session.pending_close.local_resolved=falseuntil connectivity returns. On reconnect, the local action is replayed against the server.POST /folios/:id/refundswithmethod != cash(i.e., reversing an externalpaymentId) — requirespayment-gateway-serviceto execute the refund.POST /folios/:id/close— required when issuing the invoice; tax recompute and PDF render run server-side.POST /invoices/:id/credit-notes— server-side numbering and PDF render.POST /folios/:id/reopen— supervisor override audit chain; online-only.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
| Path | Target |
|---|---|
| Cloud → desktop event apply (online) | p95 ≤ 3 s |
| Desktop → cloud charge push (online) | p95 ≤ 2 s |
| Cold reconnect (8 h offline) full pull | p95 ≤ 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":
| Scenario | Desktop UX |
|---|---|
| OCC failure on charge push after 5 retries | Yellow banner on folio: "Another change happened — refresh and re-apply". Local row marked pending_review. |
Cash payment server amount_micro disagreement | Red banner on session: "Server adjusted a cash receipt by N AFN — review session reconciliation." Folio + session re-pulled. |
| Cash drawer close request while offline | Pending close badge + "Reconnect to finalize close" CTA. |
| Tax recompute on charge changed materially after sync | Banner: "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_localcarriesexternal_payment_idopaquely (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
- Desktop runtime: ADR-0003.
- Cash workflows: 10 Payments §7.
- Reservation sync (related contract):
services/reservation-service/SYNC_CONTRACT.md. - Security on local data: SECURITY_MODEL.