Skip to main content

SYNC_CONTRACT — pricing-service

Sibling: DATA_MODEL · API_CONTRACTS · APPLICATION_LOGIC

Strategic anchors: ADR-0003 Electron offline-first desktop · 02 §10 Desktop sync model

The Ghasi Melmastoon Electron desktop is offline-first. Front-desk staff must be able to quote a walk-in price even when the WAN link to the GCP region is degraded. This contract specifies what slice of pricing data is replicated to the desktop, in which direction, and how conflicts are resolved when reconnecting.

Reminder: the desktop is Electron (Chromium + Node renderer), packaged with electron-forge. We never use Tauri; do not propose Rust-side replication.


1. Replication scope

Every desktop installation belongs to exactly one tenant + one property (a hotel deployment), so all replicated rows are filtered by (tenant_id, property_id) server-side before transmission.

TableDirectionConflict policyRationale
pricing.rate_plans (status = published)server → clientserver_authoritativeRate definitions are authored centrally
pricing.rate_plan_room_typesserver → clientserver_authoritativeLinkage follows the plan
pricing.rate_rules (rate_plan_id ∈ replicated plans, retired_at IS NULL)server → clientserver_authoritativeSame as plan
pricing.discounts (rate_plan_id ∈ replicated plans, enabled = true)server → clientserver_authoritativeSame as plan
pricing.promotions (status = active, valid_to >= today)server → clientserver_authoritativeCodes and caps are server-managed
pricing.promotion_redemptionsclient → serverappend_onlyDesktop may pre-record a redemption while offline; server reconciles cap on push
pricing.tax_rules (jurisdiction matches property)server → clientserver_authoritativeGovernment rate; never offline-edited
pricing.fee_rules (property matches)server → clientserver_authoritativeProperty-scoped, GM-edited centrally
pricing.fx_rate_snapshots (latest per pair, plus tenant pin)server → clientmax-of(captured_at)Always pick the most recent capture; cached for offline fallback
pricing_quote.price_quotes (created on desktop)client → serverappend_only (id is client-generated ULID)Quotes are immutable once pinned
pricing_quote.quote_locksserver → client (read-only mirror)lww+diff (server wins)Lock state is authoritative on the server
pricing.dynamic_suggestionsnot replicatedHITL workflow lives only in backoffice web UI
pricing.tax_rules history (superseded rows)server → client (last 90 days only)server_authoritativeNeeded for retroactive quote reads

What is not replicated to the desktop, by design:

  • The full historical FX series (only the latest snapshot per pair is replicated; history is centralised).
  • Outbox/inbox tables (server-internal).
  • Idempotency keys (server-internal).
  • Other tenants' rows (filtered by RLS at the sync gateway).

2. Replication channel

The desktop talks to desktop-sync-service (not directly to pricing-service). The sync service exposes a long-poll/streaming endpoint and proxies into pricing-service's internal sync APIs:

desktop ──HTTPS/2──▶ desktop-sync-service ──gRPC──▶ pricing-service /internal/v1/sync/*
└── Pub/Sub fan-out for push notifications

Cursor format: opaque <schema_version>:<watermark_ms>:<row_pk>. Watermarks are taken from updated_at for mutable tables and created_at for append-only tables.

GET /internal/v1/sync/rate-plans?since=01H8Z4M%3A1745312049000%3Arate_…&propertyId=pty_…
Authorization: Bearer <service-mesh-jwt>
X-Tenant-Id: tnt_…

200 OK
Content-Type: application/x-ndjson

{"op":"upsert","table":"rate_plans","row": { … }}
{"op":"upsert","table":"rate_rules","row": { … }}
{"op":"delete","table":"rate_rules","id":"rru_…"}
{"meta":{"nextCursor":"01H8Z4M:1745312102000:rule_…","hasMore":false}}

Push notifications via Firebase Cloud Messaging tell the desktop "new pricing data available, pull now"; if FCM is unreachable, the desktop falls back to a 60 s polling interval.


3. Conflict policies

3.1 server_authoritative

Used for every rate definition. The desktop NEVER edits these locally; if a manager modifies a rate plan from the desktop, the request is funnelled through the standard admin REST API (online required). On reconnect, the local copy is overwritten with the server row.

3.2 append_only

Used for price_quotes and promotion_redemptions.

  • IDs are client-generated ULIDs with the standard prefixes.
  • The desktop persists locally to a SQLite mirror with the same schema.
  • On reconnect, the sync service POSTs each unsynced row to /internal/v1/sync/price-quotes:push. Server-side validation re-runs the derivation (deterministically) using the embedded request snapshot and rejects the row if the recomputed totals.grandTotalMicro differs by more than the per-currency rounding floor; rejected rows are stored in the desktop's local sync_rejects table for operator review.
  • For promotion_redemptions, the server re-applies the race-safe redemption SQL (see DATA_MODEL §6). If the cap has been hit since the offline redemption, the redemption row is rejected with MELMASTOON.PRICING.PROMO_OVEROBLIGATION and the desktop displays a deferred warning ("This promo is now unavailable; the booking was honoured at standard rate").

3.3 max-of(captured_at)

Used for fx_rate_snapshots. Both sides always keep the most recent capture per (base, quote). The desktop checks staleAfter and hardExpireAt before using a snapshot; quotes derived against a stale snapshot are flagged with fxSnapshot.stale = true and the booking page shows a "rate may be re-confirmed at booking" banner.

3.4 lww+diff

Used for quote_locks (mirror only). Server timestamp wins. The desktop never writes to this table.


4. Offline quote derivation

While offline, the desktop runs the same TypeScript derivation pipeline as the server (shipped via the shared @melmastoon/pricing-engine package — it has no infra dependencies):

  1. Resolve candidate RatePlan from local mirror.
  2. Run deriveNightly over local RateRule, Discount, FeeRule, TaxRule, FxRateSnapshot.
  3. Emit a local PriceQuote with source: "desktop_offline" recorded in derivation.steps[*].notes.
  4. Persist to local SQLite; mark sync_status = "pending_push".

A local quote is valid for the standard TTL (30 m). When the desktop comes online and the push is rejected, the front-desk session displays the rejection inline; the guest is re-quoted at the now-authoritative server price.


5. Watermarks and back-pressure

  • Each replicated table has a pricing.sync_watermarks server-side row per (tenant_id, property_id, table) recording the last successful pull timestamp; surfaced via /internal/v1/sync/health.
  • A pull request that would exceed 5 000 rows or 5 MB is paginated; the desktop continues until hasMore=false.
  • The sync gateway enforces a per-property concurrency cap of 4 in-flight pull streams.
  • Push throttling: max 200 quotes/minute per desktop; excess is buffered locally with exponential back-off.

6. Schema evolution

Every replicated row carries a _schemaVersion field (matches version in DATA_MODEL migrations). The desktop refuses to apply a row whose _schemaVersion exceeds its bundled engine version and prompts the operator to update the desktop. Forward-incompatible changes (renames, removals) require a coordinated server + desktop release per ADR-0003 §6.


7. Security

  • The desktop holds a long-lived OAuth refresh token bound to the property's service identity; access tokens are minted with aud=desktop-sync-service and tenant_id/property_id claims.
  • All replication payloads are encrypted in transit (TLS 1.3) and at rest on the desktop (SQLCipher with a key wrapped by the OS keychain; see SECURITY_MODEL §6).
  • The desktop CANNOT read rows from another property even if the same tenant; the sync service enforces propertyId scoping in addition to RLS.
  • AI dynamic-pricing suggestions are never replicated; the HITL workflow lives only in the central backoffice.

8. Observability

The desktop emits OTel spans for desktop.sync.pull, desktop.sync.push, and desktop.pricing.derive_offline, batched and shipped to Cloud Logging via the central collector when online. Required attributes: tenant_id, property_id, table, rows, cursor.before, cursor.after, lag_ms. The lag_ms SLI tracks "how far behind authoritative pricing is the desktop"; alarm at p95 > 60 s sustained 10 m.