# 15 — Sync API Contract

**Version: 1.3.0** — agreed and versioned per [12 §9/§10](12-stack-and-platform.md) so the
branch and centre teams can build in parallel.

| Version | Change |
|---|---|
| 1.0.0 | Initial contract: push/pull envelopes, rules, event registry |
| 1.1.0 | Pull side is **live** (Phase 2): pull entity registry, back-office device registration, `X-Sync-Contract` response header |
| 1.2.0 | Phase 3 (PWA POS, [D17](14-conventions.md)): `user.pin_pbkdf2` added to the pull feed (D18); payload schemas defined for the Phase 3 event types; `business_day.closed` event added. All additive. |
| 1.3.0 | Phase 4: push side is **live** — staging table, `sync:process`, quarantine + Sync Exceptions report. Additive: quarantine reasons `branch_mismatch` (payload branch ≠ device branch) and `apply_error` (exhausted retries); `devices.last_push_at_utc`/`last_push_count` observability. |

The push side ships in Phase 4; its shape is frozen here so both sides code against it.

## Versioning policy

- The client sends `X-Sync-Contract: 1`. Absent header ⇒ 1. The centre answers
  `X-Sync-Contract` with the version it applied.
- **Additive** changes (new optional field, new event type, new quarantine reason) bump
  the minor version and require no client change.
- **Breaking** changes bump the major version; the centre keeps serving every major
  version still deployed on a branch PC. With five branches, that window is short but
  never zero — a branch may be offline for days (doc 10 §4).
- This file is the single source of truth. A PR changing it needs sign-off from both the
  branch and centre stream leads.

## Principles (doc 12 §9, doc 10 §4)

| Rule | Consequence |
|---|---|
| **Sales flow up; master data flows down.** Never a two-way merge on one entity. | Removes nearly all conflict cases by construction. |
| **`event_id` is a UUID with a unique index at the centre.** | Retry after a dropped connection is normal; re-delivery never double-posts. |
| **The centre accepts and flags — it never rejects a synced sale.** | The coffee is already drunk. Invalid events are *quarantined*, not refused. |
| **Events are append-only and ordered per device.** | The events array is in device commit order; the centre applies pushes in arrival order per device. Deterministic replay. |
| **Master data is applied at a quiet moment**, never mid-order. | A price changing between "add item" and "pay" is a real bug. |
| **Device tokens are revocable centrally.** | A stolen branch PC is cut off (doc 10 §7) and wiped on next contact. |

## Authentication

Every call carries `Authorization: Bearer <device_token>`. Tokens are issued when a
device is registered to its branch in the back office (`/admin/devices`, Owner-only,
shown exactly once — format `<device_id>.<secret>`, only a hash stored centrally) and
are revocable centrally. `401` means the device must stop syncing and surface "device
deauthorized" on the POS — it keeps trading offline; it never discards its outbox.

---

## POST /api/sync/push

Branch → centre. The endpoint is **fast and dumb** (doc 12 §5): validate the token,
insert raw events into `sync_staging` (unique index on `event_id`), return. The real work
happens in the `sync:process` cron — idempotent, chunked, resumable.

### Request

```json
{
  "device_id": "BGD1-PC-2026A",
  "branch_id": "11111111-1111-4111-8111-111111111111",
  "events": [
    {
      "event_id": "9b2b0a5e-8c3d-4f6a-9b1c-2d3e4f5a6b7c",
      "type": "order.confirmed",
      "occurred_at": "2026-08-05T06:41:00Z",
      "business_day": "2026-08-05",
      "payload": { }
    }
  ]
}
```

- `events` is limited to **500 per push**; the client loops until its outbox drains.
- `occurred_at` is UTC ISO-8601; `business_day` is the branch-computed value (doc 10 §3)
  and is authoritative — the centre never recomputes it.
- `payload` schemas are defined per event type in the registry below, each in the phase
  that introduces the type. Additive payload fields = minor version bump.

### Response `200`

```json
{
  "accepted": ["9b2b0a5e-8c3d-4f6a-9b1c-2d3e4f5a6b7c"],
  "quarantined": [
    { "event_id": "…", "reason": "unknown_reference" }
  ]
}
```

- An `event_id` already staged or applied returns in `accepted` (idempotent success).
- The client deletes an outbox row **only** when its `event_id` appears in `accepted`.
  Quarantined events are also removed from the outbox — they are the centre's problem
  now, visible on the Sync Exceptions report; the device keeps a local copy flagged
  `conflict` for support.
- Quarantine reasons (v1): `invalid_schema`, `unknown_event_type`, `unknown_reference`
  (doc 10 §5: an order referencing an unknown account is quarantined, not silently
  accepted), `payload_too_large`. v1.3 adds `branch_mismatch` (payload branch ≠ the
  device's branch) and `apply_error` (sync:process exhausted its retries — the raw
  event is kept and can be requeued from /admin/sync after the cause is fixed).

### Errors

| Status | Meaning | Client behaviour |
|---|---|---|
| `401` | Token invalid or revoked | Stop sync, alert on POS, keep trading offline |
| `413` | More than 500 events | Split the batch |
| `429`, `5xx` | Centre busy/broken | Exponential backoff from 60 s, cap 15 min; outbox untouched |

## GET /api/sync/pull?cursor=&lt;opaque&gt;

Centre → branch master data (catalog, accounts, authorized persons, users, prices,
calendar exceptions, FX rates — the "down" entities of doc 10 §4).

```json
{
  "changes": [
    { "entity": "item", "op": "upsert", "version": 412, "data": { } },
    { "entity": "item", "op": "deactivate", "version": 413, "data": { "id": "…" } }
  ],
  "next_cursor": "opaque-string",
  "has_more": false
}
```

- `cursor` is **opaque**; the device stores `next_cursor` after *applying* a page,
  atomically with the applied data. First sync sends no cursor ⇒ full snapshot pages.
- `op` is `upsert` or `deactivate` — never a hard delete (doc 10 §5 soft-delete rule).
  v1.1 emits `upsert` only; deactivation travels as an upsert of the row with its
  `is_active` / `revoked_at_utc` / `annulled_at_utc` state.
- `version` increases monotonically per entity stream; the device applies pages in order,
  **at a quiet moment** — never while an order is open (doc 10 §4).
- Pages carry at most 200 changes; a row changed twice within a page is sent once, at its
  latest version (upserts are idempotent).
- The centre answers with `X-Sync-Contract: 1`.

### Pull entity registry (v1.1)

`data` is the current row of the named table. All entities are live as of Phase 2:

| Entity | Notes |
|---|---|
| `branch`, `building`, `building_office` | Org structure (doc 01 §1) |
| `calendar_exception` | Owner-declared closures (doc 10 §3.1) — pushed to branches immediately |
| `user` | **`password_hash` is stripped**; `pin_hash` syncs down so offline PIN login works (doc 01 §2). v1.2: `pin_pbkdf2` (PBKDF2-SHA256) syncs too — the PWA verifies it offline via WebCrypto (D18) |
| `role_assignment` | Branch scoping + validity windows for terminal login |
| `category`, `item`, `modifier_group`, `modifier_option`, `item_modifier_group` | Catalog (doc 02) |
| `item_price`, `modifier_option_price` | Versioned price rows — the branch resolves exactly like the centre (doc 02 §4) |
| `branch_item_setting`, `branch_option_setting` | Availability + 86 switches (doc 02 §5) |
| `recipe_line` | Schema only until Phase 8 |
| `fx_rate` | Central rate + branch overrides (doc 10 §2) |

---

## Event type registry

Types are namespaced `entity.verb`, registered here by the phase that introduces them.
v1.0.0 fixes the envelope and reserves the names; payload schemas land with their phase
(each addition is a minor bump).

| Type | Phase | Payload (summary) |
|---|---|---|
| `order.confirmed` | 3 ✅ | Full order record incl. lines, snapshots, charges[], payments, business_day (doc 04 §2) |
| `order.voided` | 3 ✅ | order_id, reason_code, approved_by, stock/charge reversal markers |
| `order.refunded` | 3 ✅ | Opposing document referencing the original (doc 04 §6) |
| `shift.opened` / `shift.closed` | 3 ✅ | Shift record; close includes counted/expected/variance (doc 06) |
| `cash_movement.recorded` | 3 ✅ | Drawer movement with reason (doc 06 §2) |
| `business_day.closed` | 3 ✅ *(added 1.2.0)* | The frozen day-close record incl. the full Z snapshot (doc 06 §5) |
| `payment.received` | 5 | Account payment taken at POS (doc 05 §5 rule 4) |
| `audit.event` | 2+ ✅ | Audit events sync with the same guarantees as sales (doc 01 §4) |
| `stock_movement.recorded` | 8 | Movement ledger entries (doc 07 §3) |
| `waste.recorded` / `count.submitted` | 8 | Doc 07 §7/§8 |

### Phase 3 payload schemas (v1.2.0)

Payloads are the JSON documents the PWA persists locally — field-for-field. The
authoritative producer is `public/pos/js/domain/` (orders.js, shifts.js, dayclose.js);
each event's payload is the full row it commits, so the centre-side `sync:process`
(Phase 4) applies them without transformation:

- **`order.confirmed`** — the order row: `id`, `type:'sale'`, `order_number`
  (`<CODE>-<YYYYMMDD>-<seq>`), `branch_id`, `device_id`, `shift_id`, `cashier_user_id`,
  `business_day`, `created_at_utc/_local`, `buyer_type`, `charges[]` (empty until
  Phase 5), `fulfilment`, `office_ref?`, `lines[]` (snapshots incl. `modifiers[]`,
  `line_discount`, `prep_station`), `subtotal`, `discount_total`, `order_discount*`,
  `tax_total` (always 0 — doc 00 §6), `grand_total`, `cash_rounding_house_gain`,
  `settlement_type`, `payments[]` (per-leg: `tender`, `currency`, `amount_iqd`,
  `tendered_iqd?`, `change_iqd?`, `usd_minor?`, `fx_rate?`, `iqd_equivalent?`,
  `cash_rounding_house_gain`, `ref?`), `status`, `late_arrival`, `note?`.
- **`order.voided`** — `order_id`, `order_number`, `reason_code`, `voided_by_user_id`,
  `approved_by_user_id?`, `prep_state_at_void`, `grand_total`.
- **`order.refunded`** — the refund document (same shape as an order, `type:'refund'`,
  plus `refund_of_order_id`, `original_order_number`, `reason_code`,
  `approved_by_user_id?`, payments carry `direction:'out'`).
- **`shift.opened` / `shift.closed`** — the shift row; close adds `counted_cash`,
  `counted_denominations`, `counted_usd_minor`, `expected_cash`, `expected_usd_minor`,
  `variance`, `variance_reason?`, `approved_by_user_id?`, `crossed_business_day`,
  `escalated`.
- **`cash_movement.recorded`** — `id`, `shift_id`, `branch_id`, `business_day`, `type`,
  `direction`, `amount_iqd`, `reason_code`, `note?`, `user_id`, `approved_by_user_id?`,
  `at_utc`.
- **`business_day.closed`** — `business_day`, `branch_id`, `closed_by_user_id`,
  `closed_at_utc`, `z` (the full frozen Z snapshot).
- **`audit.event`** — the doc 01 §4 audit row exactly as written locally.

**All Phase 4 machinery is live (v1.3.0):** `POST /api/sync/push` stages into
`sync_staging` (unique `event_id`; envelope-invalid events quarantined, stored,
reported); the `sync:process` cron applies in per-device order into the central
orders/shifts/movements/day-closes/audit tables — idempotent by natural id, chunked,
resumable; `rollups:build` rebuilds the reporting tables from source; `alerts:scan`
opens/auto-resolves stale-device alerts. The PWA push client drains its outbox every
minute when online (batches ≤200, exponential backoff 60 s → 15 min, rows deleted only
on `accepted`; quarantined rows move to the local `conflicts` store flagged for
support). Sync Exceptions + device health: `/admin/sync`.
