@teincfood/core 0.10.8 → 0.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,13 @@ export declare const usePendingOrdersStore: {
14
14
  hydrated: boolean;
15
15
  orders: SellerOrder[];
16
16
  addOrder: (order: SellerOrder) => void;
17
+ /**
18
+ * Replace an existing pending order in place. Used for queued
19
+ * transitions/payments so the optimistic overlay reflects the new state
20
+ * immediately. Unknown orders are ignored (a transition must never
21
+ * resurrect an order that was already reconciled).
22
+ */
23
+ updateOrder: (order: SellerOrder) => void;
17
24
  removeByOrderNumber: (orderNumber: string) => void;
18
25
  removeByOrderId: (orderId: string) => void;
19
26
  setHydrated: (v: boolean) => void;
@@ -30,6 +30,25 @@ export const usePendingOrdersStore = {
30
30
  notify();
31
31
  }
32
32
  },
33
+ /**
34
+ * Replace an existing pending order in place. Used for queued
35
+ * transitions/payments so the optimistic overlay reflects the new state
36
+ * immediately. Unknown orders are ignored (a transition must never
37
+ * resurrect an order that was already reconciled).
38
+ */
39
+ updateOrder: (order) => {
40
+ const index = orders.findIndex((o) => o.id === order.id || o.order_number === order.order_number);
41
+ if (index === -1)
42
+ return;
43
+ const current = orders[index];
44
+ if (current.status === order.status &&
45
+ current.is_paid === order.is_paid &&
46
+ current.paid_at === order.paid_at) {
47
+ return;
48
+ }
49
+ orders = orders.map((o, i) => (i === index ? order : o));
50
+ notify();
51
+ },
33
52
  removeByOrderNumber: (orderNumber) => {
34
53
  const next = orders.filter((o) => o.order_number !== orderNumber);
35
54
  if (next.length !== orders.length) {
@@ -112,6 +112,15 @@ export declare function upsertServerOrder(db: SQLiteDatabase, order: import("../
112
112
  preserveStatus?: boolean;
113
113
  preservePayment?: boolean;
114
114
  }): Promise<void>;
115
+ /**
116
+ * Recent orders plus their local HLC, for LAN catch-up.
117
+ *
118
+ * The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
119
+ * replaying an older snapshot can never overwrite a newer local status.
120
+ */
121
+ export declare function listOrdersWithHlc(db: SQLiteDatabase, businessId: string, limit?: number): Promise<Array<import("../types/api/seller.api").SellerOrder & {
122
+ hlc?: string;
123
+ }>>;
115
124
  export declare function listOrders(db: SQLiteDatabase, businessId: string, filters?: {
116
125
  status?: string;
117
126
  q?: string;
@@ -376,6 +376,19 @@ export async function upsertServerOrder(db, order, opts = {}) {
376
376
  ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
377
377
  notifyDbChange(order.business_id, "orders");
378
378
  }
379
+ /**
380
+ * Recent orders plus their local HLC, for LAN catch-up.
381
+ *
382
+ * The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
383
+ * replaying an older snapshot can never overwrite a newer local status.
384
+ */
385
+ export async function listOrdersWithHlc(db, businessId, limit = 200) {
386
+ const rows = await db.getAllAsync(`SELECT * FROM orders WHERE business_id = ? AND deleted_at IS NULL ORDER BY inserted_at DESC LIMIT ?;`, businessId, limit);
387
+ return rows.map((row) => ({
388
+ ...orderRowToSellerOrder(row),
389
+ hlc: row.hlc ?? row.updated_at ?? row.inserted_at,
390
+ }));
391
+ }
379
392
  export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
380
393
  const where = ["business_id = ?"];
381
394
  const args = [businessId];
@@ -22,7 +22,7 @@ export interface SellerOrderFilters {
22
22
  date_to?: string;
23
23
  }
24
24
  export declare function useSellerOrdersQuery(enabled?: boolean, filters?: SellerOrderFilters): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
25
- export declare function useSellerOrderQuery(orderId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
25
+ export declare function useSellerOrderQuery(orderId: string, enabled?: boolean, placeholder?: SellerOrder | null): import("@tanstack/react-query").UseQueryResult<{
26
26
  data: SellerOrder;
27
27
  }, Error>;
28
28
  export declare function useCreatePOSOrderMutation(): import("@tanstack/react-query").UseMutationResult<SellerOrder, Error, POSOrderCreateRequest, unknown>;
@@ -81,7 +81,7 @@ export function useSellerOrdersQuery(enabled = true, filters) {
81
81
  select: (data) => data.pages.flatMap((p) => p.data),
82
82
  });
83
83
  }
84
- export function useSellerOrderQuery(orderId, enabled = true) {
84
+ export function useSellerOrderQuery(orderId, enabled = true, placeholder) {
85
85
  const bid = useBusinessId();
86
86
  return useQuery({
87
87
  queryKey: SELLER_KEYS.order(orderId, bid),
@@ -94,6 +94,12 @@ export function useSellerOrderQuery(orderId, enabled = true) {
94
94
  return { data: order };
95
95
  },
96
96
  enabled: enabled && !!orderId && !!bid,
97
+ // The order list already has the full row. Seed the dialog with it so
98
+ // opening details is instant; the DB read still runs and replaces it.
99
+ placeholderData: placeholder ? { data: placeholder } : undefined,
100
+ // Local SQLite reads cannot recover from a transient network error; a
101
+ // missing row should surface immediately instead of retrying for seconds.
102
+ retry: false,
97
103
  staleTime: 5 * 1000,
98
104
  });
99
105
  }
@@ -24,6 +24,18 @@ declare class LocalNodeServiceCore {
24
24
  init(deps: LocalNodeDeps): void;
25
25
  private requestReferenceFromPeers;
26
26
  private requestAssetFromPeers;
27
+ /**
28
+ * Catch a newly connected peer up on orders created while the mesh was not
29
+ * connected (e.g. a sale rung up before Local Node was enabled).
30
+ *
31
+ * Live order events only cover changes that happen *after* the socket is
32
+ * up. Without this handshake a pre-existing offline sale stayed invisible
33
+ * on the other terminal forever. Both sides call this on connect; the
34
+ * cooldown dedupes the inbound/outbound callbacks for the same peer.
35
+ */
36
+ private orderCatchupAt;
37
+ private static readonly ORDER_CATCHUP_COOLDOWN_MS;
38
+ private publishOrderCatchup;
27
39
  private getDeps;
28
40
  private getDeviceId;
29
41
  private runLifecycle;
@@ -12,7 +12,9 @@ import { REFERENCE_CHANGED_EVENT_TYPE } from "../reference/events";
12
12
  import { applyReferenceChangeEvent } from "../reference/events";
13
13
  import { referenceSyncService } from "../reference/service";
14
14
  import { openSyncDatabase } from "../db/connection";
15
- import { insertOrder } from "../db/operations";
15
+ import { getUnsyncedEvents, insertOrder, listOrdersWithHlc } from "../db/operations";
16
+ import { getPendingOrdersSnapshot } from "../adapters/pending-orders";
17
+ import { generateEventId } from "../utils/uuid";
16
18
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
17
19
  import { getConnectivityState } from "../adapters/connectivity";
18
20
  import { deriveLanSecret as deriveSecretCore } from "./security";
@@ -46,6 +48,16 @@ class LocalNodeServiceCore {
46
48
  this.published = false;
47
49
  /** Whether discovery scanning was successfully started. */
48
50
  this.discovering = false;
51
+ /**
52
+ * Catch a newly connected peer up on orders created while the mesh was not
53
+ * connected (e.g. a sale rung up before Local Node was enabled).
54
+ *
55
+ * Live order events only cover changes that happen *after* the socket is
56
+ * up. Without this handshake a pre-existing offline sale stayed invisible
57
+ * on the other terminal forever. Both sides call this on connect; the
58
+ * cooldown dedupes the inbound/outbound callbacks for the same peer.
59
+ */
60
+ this.orderCatchupAt = new Map();
49
61
  this.storeUnsub = null;
50
62
  this.dialInFlight = new Set();
51
63
  this.dialTimers = new Map();
@@ -89,6 +101,74 @@ class LocalNodeServiceCore {
89
101
  }
90
102
  return null;
91
103
  }
104
+ async publishOrderCatchup(peerDeviceId) {
105
+ if (!this.businessId)
106
+ return;
107
+ const now = Date.now();
108
+ const last = this.orderCatchupAt.get(peerDeviceId) ?? 0;
109
+ if (now - last < LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS)
110
+ return;
111
+ this.orderCatchupAt.set(peerDeviceId, now);
112
+ try {
113
+ const db = await openSyncDatabase();
114
+ const rows = await listOrdersWithHlc(db, this.businessId, 200);
115
+ const byId = new Map();
116
+ for (const order of rows)
117
+ byId.set(order.id, order);
118
+ // Queued offline creations have no `orders` row yet; they live in the
119
+ // pending overlay and the durable event journal. Include both so a
120
+ // pre-linkup sale is shared even after an app restart.
121
+ for (const origin of ["local", "lan"]) {
122
+ const events = await getUnsyncedEvents(db, origin, Number.MAX_SAFE_INTEGER);
123
+ for (const event of events) {
124
+ if (event.event_type !== "order.create_queued")
125
+ continue;
126
+ const order = event.payload;
127
+ if (order?.id && order.order_number)
128
+ byId.set(order.id, order);
129
+ }
130
+ }
131
+ for (const order of getPendingOrdersSnapshot().orders) {
132
+ if (order.business_id === this.businessId)
133
+ byId.set(order.id, order);
134
+ }
135
+ const peer = this.peers.get(peerDeviceId);
136
+ const canTarget = !!peer?.isConnected() && typeof peer.publishEvent === "function";
137
+ for (const order of byId.values()) {
138
+ if (!order?.id || !order.order_number)
139
+ continue;
140
+ const event = {
141
+ event_id: generateEventId(),
142
+ event_type: "order:sync",
143
+ event_version: "1",
144
+ entity_id: order.id,
145
+ user_id: "",
146
+ device_id: this.cachedDeviceId ?? "",
147
+ payload: order,
148
+ timestamp: order.updated_at ?? order.inserted_at ?? new Date().toISOString(),
149
+ };
150
+ if (canTarget) {
151
+ await peer.publishEvent(event);
152
+ }
153
+ else {
154
+ this.server?.broadcast(event);
155
+ }
156
+ }
157
+ syncLogger.info("LocalNodeService", "Order catch-up published", {
158
+ peer: peerDeviceId,
159
+ orders: byId.size,
160
+ targeted: canTarget,
161
+ });
162
+ }
163
+ catch (error) {
164
+ syncLogger.warn("LocalNodeService", "Order catch-up failed", {
165
+ peer: peerDeviceId,
166
+ error: String(error),
167
+ });
168
+ // Allow a later reconnect to retry after a transient DB/socket failure.
169
+ this.orderCatchupAt.delete(peerDeviceId);
170
+ }
171
+ }
92
172
  getDeps() {
93
173
  if (!this.deps)
94
174
  throw new Error("LocalNodeService not initialised: call initLocalNodeService(deps) first");
@@ -200,6 +280,7 @@ class LocalNodeServiceCore {
200
280
  catch { }
201
281
  this.peers.clear();
202
282
  this.peerInfos.clear();
283
+ this.orderCatchupAt.clear();
203
284
  if (this.server) {
204
285
  await withTimeout(this.server.stop(), LIFECYCLE_TIMEOUT_MS).catch((e) => syncLogger.warn("LocalNodeService", "Server stop timed out", { error: String(e) }));
205
286
  this.server = null;
@@ -359,6 +440,9 @@ class LocalNodeServiceCore {
359
440
  onClientConnected: (info) => {
360
441
  localNodeStore.getState().addConnectedClient({ deviceId: info.deviceId, deviceName: info.deviceName, host: info.host, connectedAt: new Date().toISOString() });
361
442
  syncLogger.info("LocalNodeService", "Client connected", { deviceId: info.deviceId, connected_clients: localNodeStore.getState().connectedClients.length });
443
+ // Inbound connect: push this node's pre-existing orders to the new
444
+ // peer (covers mDNS asymmetry where only one side dials).
445
+ void this.publishOrderCatchup(info.deviceId);
362
446
  },
363
447
  onClientDisconnected: (deviceId) => {
364
448
  localNodeStore.getState().removeConnectedClient(deviceId);
@@ -602,6 +686,8 @@ class LocalNodeServiceCore {
602
686
  syncLogger.info("LocalNodeService", "Connected to peer", { deviceId: node.deviceId });
603
687
  this.peerInfos.set(node.deviceId, { deviceId: node.deviceId, host: node.host, port: node.port });
604
688
  localNodeStore.getState().addConnectedClient({ deviceId: node.deviceId, deviceName: node.deviceName, host: node.host, connectedAt: new Date().toISOString() });
689
+ // Outbound connect: push orders created before this socket existed.
690
+ void this.publishOrderCatchup(node.deviceId);
605
691
  // A terminal may have missed `reference_data.changed` while it was
606
692
  // disconnected. Compare versions with the peer on reconnect so menu,
607
693
  // members, hours and tax changes converge even on a dark LAN.
@@ -817,6 +903,7 @@ class LocalNodeServiceCore {
817
903
  }
818
904
  getState() { return { isLocalNode: this.isLocalNode(), isConnectedToNode: this.isConnectedToNode(), electedNode: this.getElectedNode(), client: this.getClient() }; }
819
905
  }
906
+ LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS = 10000;
820
907
  LocalNodeServiceCore.MISMATCH_BACKOFF_MS = 60000;
821
908
  export const localNodeService = new LocalNodeServiceCore();
822
909
  export function initLocalNodeService(deps) { localNodeService.init(deps); return localNodeService; }
@@ -18,6 +18,7 @@ import { assertBusinessOpenForCreate } from "../utils/business-hours";
18
18
  import { extractApiErrorCode } from "../utils/error";
19
19
  import { generateEventId } from "../utils/uuid";
20
20
  import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
21
+ import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
21
22
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
22
23
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
23
24
  import { canBusinessSync } from "./sync-guard";
@@ -53,7 +54,7 @@ const PAYMENT_QUEUED_PAYLOAD = {
53
54
  export function getQueuedStatus(commandType) {
54
55
  return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
55
56
  }
56
- function buildOptimisticEvent(command) {
57
+ function buildOptimisticEvent(command, flowMode = DEFAULT_ORDER_FLOW_MODE) {
57
58
  const payload = command.payload;
58
59
  if (command.command_type === "order:create") {
59
60
  return {
@@ -63,7 +64,7 @@ function buildOptimisticEvent(command) {
63
64
  entity_id: command.entity_id ?? command.command_id,
64
65
  user_id: command.user_id,
65
66
  device_id: command.device_id,
66
- payload: buildOptimisticOrder(command, payload),
67
+ payload: buildOptimisticOrder(command, payload, flowMode),
67
68
  timestamp: new Date().toISOString(),
68
69
  };
69
70
  }
@@ -77,6 +78,7 @@ function buildOptimisticEvent(command) {
77
78
  device_id: command.device_id,
78
79
  payload: {
79
80
  ...payload,
81
+ business_id: command.business_id,
80
82
  command_id: command.command_id,
81
83
  is_paid: true,
82
84
  paid_at: new Date().toISOString(),
@@ -95,6 +97,9 @@ function buildOptimisticEvent(command) {
95
97
  device_id: command.device_id,
96
98
  payload: {
97
99
  ...payload,
100
+ // Required by the offline read-model patch so queued transitions update
101
+ // the canonical orders table (not just seller_orders snapshots).
102
+ business_id: command.business_id,
98
103
  command_id: command.command_id,
99
104
  status: "pending",
100
105
  // Used by offline read-model patching so the list reflects the queued transition.
@@ -106,7 +111,7 @@ function buildOptimisticEvent(command) {
106
111
  timestamp: new Date().toISOString(),
107
112
  };
108
113
  }
109
- function buildOptimisticOrder(command, payload) {
114
+ function buildOptimisticOrder(command, payload, flowMode) {
110
115
  const snapshots = buildItemSnapshots((payload.item_snapshots ?? payload.items ?? []));
111
116
  const totalMinor = payload.total_minor ?? 0;
112
117
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
@@ -114,6 +119,8 @@ function buildOptimisticOrder(command, payload) {
114
119
  const taxLines = payload.tax_lines ?? [];
115
120
  const currencyCode = payload.currency_code ?? "GHS";
116
121
  const now = new Date().toISOString();
122
+ const method = payload.pos_payment_method ?? "cash";
123
+ const isCash = method === "cash";
117
124
  const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
118
125
  return {
119
126
  id: command.entity_id ?? command.command_id,
@@ -123,20 +130,23 @@ function buildOptimisticOrder(command, payload) {
123
130
  is_pos: true,
124
131
  customer_name: payload.customer_name ?? null,
125
132
  customer_phone: payload.customer_phone ?? null,
126
- pos_payment_method: payload.pos_payment_method ?? "cash",
127
- paid_at: null,
128
- status: "accepted",
133
+ pos_payment_method: method,
134
+ paid_at: isCash ? now : null,
135
+ // Must match createLocalOrder: direct-flow cash sales are born delivered,
136
+ // even when the command is parked in the offline outbox. Hardcoding
137
+ // "accepted" made a completed sale show up on the preparing board.
138
+ status: initialPosOrderStatus(method, flowMode),
129
139
  delivery_id: null,
130
140
  delivery_status: null,
131
- is_paid: payload.pos_payment_method === "cash",
141
+ is_paid: isCash,
132
142
  payment: {
133
- is_paid: payload.pos_payment_method === "cash",
143
+ is_paid: isCash,
134
144
  reference: null,
135
145
  amount_minor: totalMinor,
136
146
  currency: currencyCode,
137
- paid_at: payload.pos_payment_method === "cash" ? now : null,
147
+ paid_at: isCash ? now : null,
138
148
  },
139
- payment_mode: "pos_cash",
149
+ payment_mode: isCash ? "pos_cash" : "prepaid",
140
150
  total_minor: totalMinor,
141
151
  subtotal_minor: subtotalMinor,
142
152
  delivery_fee_minor: payload.delivery_fee_minor ?? 0,
@@ -268,7 +278,13 @@ class CommandQueueService {
268
278
  order_number: preparedCommand.payload.order_number ?? null,
269
279
  outbox_size: pending.length,
270
280
  });
271
- const event = buildOptimisticEvent(preparedCommand);
281
+ // Direct-flow cash sales are complete on creation. The optimistic order
282
+ // must use the same cached business setting as createLocalOrder, otherwise
283
+ // an offline direct sale appears as "accepted" and offers kitchen steps.
284
+ const flowMode = preparedCommand.command_type === "order:create"
285
+ ? await getBusinessOrderFlowMode(preparedCommand.business_id)
286
+ : DEFAULT_ORDER_FLOW_MODE;
287
+ const event = buildOptimisticEvent(preparedCommand, flowMode);
272
288
  syncLogger.info("CommandQueueService", "Optimistic event built", {
273
289
  event_type: event.event_type,
274
290
  entity_id: event.entity_id,
@@ -220,7 +220,7 @@ function patchPendingStatus(event) {
220
220
  if (!target)
221
221
  return;
222
222
  const patched = { ...target, status: toStatus };
223
- store.addOrder(patched);
223
+ store.updateOrder(patched);
224
224
  }
225
225
  catch { }
226
226
  }
@@ -244,7 +244,7 @@ function patchPendingPaid(event) {
244
244
  paid_at: paidAt,
245
245
  payment: { ...t.payment, is_paid: true, paid_at: paidAt },
246
246
  };
247
- store.addOrder(patched);
247
+ store.updateOrder(patched);
248
248
  }
249
249
  catch { }
250
250
  }
@@ -277,7 +277,7 @@ export async function applyPendingOrderEvent(event, origin = "local") {
277
277
  const store = usePendingOrdersStore.getState();
278
278
  const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
279
279
  if (target)
280
- store.addOrder({ ...target, status: toStatus });
280
+ store.updateOrder({ ...target, status: toStatus });
281
281
  }
282
282
  }
283
283
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",