Package
nu_pos_sync_protocol/ — Shared TypeScript package consumed by the hub (@nu/sync-protocol).
nu_pos_sync_protocol/src/
├── messages.ts # TerminalMessage | HubMessage discriminated unions
├── snapshots.ts # OrderSnapshot, TableStatus types
├── constants.ts # Timing constants
├── validators.ts # Zod schemas + parse helpers
└── index.ts # Re-exports
Message types
All WebSocket communication uses JSON-encoded messages with atype discriminator field.
Terminal to hub (13 message types)
| Type | Purpose | Key fields |
|---|---|---|
AUTH | Authenticate on connect | token, deviceId |
LEASE_ACQUIRE | Request exclusive edit rights | posReference, baseVersion, force? |
LEASE_RELEASE | Release edit rights | posReference, finalSnapshot? |
LEASE_HEARTBEAT | Keep lease alive | posReference |
ORDER_SNAPSHOT | Send full order state | posReference, baseVersion, snapshot |
ORDER_FINALIZE | Finalize order through hub | posReference, snapshot, configId |
TABLE_UPDATE | Update table status | tableId, status, posReference? |
TABLE_LOCK_ACQUIRE | Request table lock | tableId |
TABLE_LOCK_RELEASE | Release table lock | tableId |
TABLE_LOCK_HEARTBEAT | Keep table lock alive | tableId |
TABLE_LOCK_FORCE_ACQUIRE | Manager table lock override | tableId |
TICKET_NUMBER_REQUEST | Request sequential ticket number | orderType ("takeout" or "delivery") |
FLOOR_PLAN_UPDATE | Broadcast floor plan changes | floorId, tables, layoutMode? |
Hub to terminal (19 message types)
| Type | Purpose | Key fields |
|---|---|---|
AUTH_OK | Authentication success | terminalId, role |
AUTH_FAIL | Authentication failure | reason |
LEASE_GRANTED | Lease acquired | posReference, version, expiresAt |
LEASE_DENIED | Lease rejected | posReference, holder, reason |
LEASE_REVOKED | Lease taken away | posReference, reason, snapshot? |
ORDER_UPDATED | Order changed by another terminal | posReference, version, snapshot, sourceTerminal |
ORDER_FINALIZED | Order finalization success | posReference, orderId, lineIdMap, fiscal? |
ORDER_FINALIZE_ERROR | Order finalization failure | posReference, error, retryable |
SYNC_INIT | Full order state on connect | orders: [{ posReference, version, snapshot }] |
TABLE_UPDATED | Table status changed | tableId, status, posReference? |
CONFLICT | Version conflict detected | posReference, hubVersion, hubSnapshot |
SYNC_STATUS | Periodic hub health broadcast | connectedTerminals, pendingForward, lastOdooSync |
TABLE_LOCK_GRANTED | Table lock acquired | tableId |
TABLE_LOCK_DENIED | Table lock rejected | tableId, lockedBy: { userId, userName } |
TABLE_LOCK_RELEASED | Table lock released | tableId |
TABLE_LOCK_REVOKED | Table lock taken away | tableId, reason |
TABLE_LOCKS_STATE | Full table lock snapshot | locks: [{ tableId, terminalId, userId, userName }] |
TICKET_NUMBER_RESPONSE | Ticket number assigned | ticketNumber, orderType |
TICKET_NUMBER_ERROR | Ticket number failure | reason, orderType? |
FLOOR_PLAN_CHANGED | Floor plan changed by another terminal | floorId, tables, layoutMode?, sourceTerminal |
OrderSnapshot
The canonical representation of an order’s state, transmitted inORDER_SNAPSHOT, ORDER_UPDATED, CONFLICT, and LEASE_REVOKED messages.
interface OrderSnapshot {
posReference: string; // Required — idempotency key
lines: OrderLineSnapshot[]; // Order lines
totals: OrderTotalsSnapshot; // { subtotal, tax, total }
updatedAt: string; // ISO timestamp
// Optional
id?: string; // Client-side UUID
odooId?: number; // Odoo pos.order ID (set after first sync)
lineIdMap?: Record<string, number>; // Client line ID → Odoo line ID
partnerId?: string | null;
tableId?: number;
status?: "open" | "sent" | "changed" | "checkout" | "paid" | "closed";
notes?: string;
paymentLines?: PaymentLineSnapshot[];
amountPaid?: number;
amountReturn?: number;
terminalId?: string;
// Order metadata
guestCount?: number;
alias?: string; // Check alias (e.g. "Bar", "Patio")
staffName?: string; // Staff member name
staffUserId?: string; // Staff member user ID
createdAt?: string; // ISO timestamp — order creation time
orderType?: "dine_in" | "takeout" | "delivery";
ticketNumber?: string; // Sequential ticket number for takeout/delivery
fiscalPositionId?: number | null;
// Check-level discount audit
checkDiscount?: {
presetId?: number;
presetName?: string;
type: "percent" | "amount";
originalValue: number;
discountPct: number;
appliedByUserId: string;
appliedByName: string;
approvedByUserId?: string;
approvedByName?: string;
};
}
OrderLineSnapshot
interface OrderLineSnapshot {
id: string; // Client-generated UUID
productId: string;
quantity: number;
unitPrice: number;
total: number;
discount?: number;
taxIds?: string[];
totalExcluded?: number;
totalIncluded?: number;
taxAmount?: number;
note?: string; // Modifier text (side dishes, etc.)
specialRequest?: string; // Special request tags
// Void tracking
isVoided?: boolean;
voidReasonId?: number;
voidedQty?: number;
// Kitchen provenance
kitchenSentAt?: string; // ISO — when item was first sent to kitchen
sentQuantity?: number; // Qty sent (detects post-send quantity increases)
kitchenTicketId?: string; // Original order ID (split check provenance)
// Per-line user attribution
addedByUserId?: string;
addedByName?: string;
// Price override
priceOverride?: number;
// Discount audit trail
discountState?: {
presetId?: number;
presetName?: string;
type: "percent" | "amount";
originalValue: number;
discountPct: number;
appliedByUserId: string;
appliedByName: string;
approvedByUserId?: string;
approvedByName?: string;
};
// Price override audit trail
priceOverrideState?: {
originalPrice: number;
overriddenByUserId: string;
overriddenByName: string;
approvedByUserId?: string;
approvedByName?: string;
};
}
Voided lines (
isVoided: true) are included in snapshots for audit purposes but filtered out before forwarding to Odoo.FiscalData
Returned inORDER_FINALIZED messages for Dominican tax (NCF/DGII) integration:
interface FiscalData {
ncf?: string;
ncfCreationDate?: string;
ncfAffected?: string;
fiscalPositionName?: string;
ncfExpiration?: string;
stampUrl?: string;
securityCode?: string;
validationDate?: string;
customerName?: string;
customerRnc?: string;
}
Protocol constants
Defined innu_pos_sync_protocol/src/constants.ts:
| Constant | Value | Purpose |
|---|---|---|
PROTOCOL_VERSION | 1 | Bump on breaking wire format changes |
LEASE_TTL_MS | 60,000 (60s) | Lease validity without heartbeat |
HEARTBEAT_MS | 20,000 (20s) | Terminal heartbeat frequency |
GRACE_MS | 10,000 (10s) | Grace period after disconnect before lease revocation |
FORWARD_INTERVAL_MS | 5,000 (5s) | Hub event drain to Odoo interval |
FORWARD_BATCH_SIZE | 20 | Max events per drain batch |
RECONNECT_BASE_MS | 1,000 (1s) | Reconnect backoff base (doubles each attempt) |
RECONNECT_MAX_MS | 30,000 (30s) | Max reconnect delay |
TABLE_LOCK_TTL_MS | 45,000 (45s) | Table lock validity without heartbeat |
TABLE_LOCK_HEARTBEAT_MS | 15,000 (15s) | Table lock heartbeat frequency |
Validation
Every message is validated at runtime using Zod schemas (validators.ts). The hub validates incoming terminal messages before routing; the frontend can optionally validate hub messages.
import { parseTerminalMessage, parseHubMessage } from "@nu/sync-protocol";
const result = parseTerminalMessage(rawData);
if (result.ok) {
// result.message is typed as TerminalMessage
} else {
// result.error describes what failed
}
z.discriminatedUnion on the type field for efficient runtime dispatch.
Connection lifecycle
Terminal Hub
│ │
├──── WS Connect ──────────────────►│
│ │
├──── AUTH { token } ──────────────►│
│ │ validate token → Odoo
│◄─── AUTH_OK { terminalId } ───────┤
│ │
├──── LEASE_ACQUIRE { ref } ───────►│
│◄─── LEASE_GRANTED { version } ────┤
│ │
├──── ORDER_SNAPSHOT { snap } ─────►│
│ ├──► broadcast ORDER_UPDATED to others
│ ├──► append to event log
│ │
│ (every 20s) │
├──── LEASE_HEARTBEAT { ref } ─────►│
│ │ renew lease expiry
│ │
├──── LEASE_RELEASE { ref } ───────►│
│ │
├──── WS Close ─────────────────────►│
│ │ grace period → revoke leases