@teincfood/core 0.9.23 → 0.9.25

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.
@@ -423,4 +423,29 @@ export const MIGRATIONS = [
423
423
  `);
424
424
  },
425
425
  },
426
+ {
427
+ version: 12,
428
+ up: async (db) => {
429
+ // Payment timestamp at the top level of the order.
430
+ //
431
+ // The server returns `paid_at` on the order and (for cash sales taken at
432
+ // the till) no payment transaction at all. Keeping the timestamp only
433
+ // inside the `payment` JSON blob meant every pull overwrote a locally
434
+ // paid cash order with the server's `{is_paid:false, paid_at:null}`,
435
+ // so the row read back as UNPAID. One column, backfilled from the blob,
436
+ // is the single local source of truth.
437
+ await db.execAsync(`
438
+ ALTER TABLE orders ADD COLUMN paid_at TEXT;
439
+ `);
440
+ try {
441
+ await db.execAsync(`
442
+ UPDATE orders
443
+ SET paid_at = json_extract(payment, '$.paid_at')
444
+ WHERE paid_at IS NULL
445
+ AND json_extract(payment, '$.paid_at') IS NOT NULL;
446
+ `);
447
+ }
448
+ catch { }
449
+ },
450
+ },
426
451
  ];
@@ -66,14 +66,52 @@ export interface OrderRow {
66
66
  inserted_at: string;
67
67
  updated_at: string | null;
68
68
  hlc: string | null;
69
+ paid_at?: string | null;
69
70
  deleted_at: string | null;
70
71
  sync_failed_at?: string | null;
71
72
  sync_error?: string | null;
72
73
  }
74
+ /** True when the order has any recorded payment. */
75
+ export declare function isOrderPaid(order: {
76
+ is_paid?: boolean;
77
+ paid_at?: string | null;
78
+ payment?: unknown;
79
+ } | null | undefined): boolean;
73
80
  export declare function insertOrder(db: SQLiteDatabase, order: import("../types/api/seller.api").SellerOrder & {
74
81
  hlc?: string;
75
82
  }): Promise<void>;
76
83
  export declare function getOrderById(db: SQLiteDatabase, businessId: string, idOrNumber: string): Promise<import("../types/api/seller.api").SellerOrder | null>;
84
+ /**
85
+ * Local intent for an order that still has unsynced work in `sync_jobs`.
86
+ *
87
+ * While a command is queued the device's intent must win over the server
88
+ * copy: a pull that lands before the command syncs would otherwise revert a
89
+ * queued accept (or a queued cash payment) back to the server's older state.
90
+ *
91
+ * `status` is the target status of the LAST queued transition, `isPaid` is
92
+ * true when a cash confirmation is still queued.
93
+ */
94
+ export interface PendingOrderIntent {
95
+ status: string | null;
96
+ isPaid: boolean;
97
+ hasPending: boolean;
98
+ }
99
+ export declare function getPendingOrderIntent(db: SQLiteDatabase, businessId: string, orderId: string): Promise<PendingOrderIntent>;
100
+ /**
101
+ * Write a server order into the local read model.
102
+ *
103
+ * Unlike `insertOrder` (which is HLC-guarded for local/LAN writes), the
104
+ * server copy is authoritative: it always overwrites the row. The only
105
+ * exception is an order with unsynced local work — its status and payment
106
+ * stay as the device intended until the queued command is acknowledged.
107
+ *
108
+ * The HLC is taken from the server's own `updated_at` so device clock skew
109
+ * can never make a fresh server state look stale.
110
+ */
111
+ export declare function upsertServerOrder(db: SQLiteDatabase, order: import("../types/api/seller.api").SellerOrder, opts?: {
112
+ preserveStatus?: boolean;
113
+ preservePayment?: boolean;
114
+ }): Promise<void>;
77
115
  export declare function listOrders(db: SQLiteDatabase, businessId: string, filters?: {
78
116
  status?: string;
79
117
  q?: string;
@@ -2,6 +2,11 @@
2
2
  * Low-level SQLite operations for the command outbox and event journal.
3
3
  */
4
4
  import { syncLogger } from "../utils/sync-logger";
5
+ import { getQueuedStatus } from "../utils/order-transitions";
6
+ /** Target status a queued command will produce on the server. */
7
+ function queuedStatusOf(commandType) {
8
+ return getQueuedStatus(commandType);
9
+ }
5
10
  export async function insertCommand(db, input) {
6
11
  const { command, status = "pending" } = input;
7
12
  const now = new Date().toISOString();
@@ -193,7 +198,47 @@ function rowToDomainEvent(row) {
193
198
  timestamp: row.timestamp,
194
199
  };
195
200
  }
201
+ const EMPTY_PAYMENT = {
202
+ is_paid: false,
203
+ reference: null,
204
+ amount_minor: null,
205
+ currency: null,
206
+ paid_at: null,
207
+ };
208
+ /**
209
+ * Effective payment of a stored order.
210
+ *
211
+ * `payment` (server payment summary) is the primary shape, but the order's
212
+ * own `paid_at` column wins when the server did not report a payment. Cash
213
+ * sales taken at a POS terminal only carry `paid_at` — no payment
214
+ * transaction — so reading the blob alone made them look unpaid.
215
+ */
216
+ function paymentStateOf(row) {
217
+ let parsed = {};
218
+ if (row.payment) {
219
+ try {
220
+ const value = JSON.parse(row.payment);
221
+ if (value && typeof value === "object")
222
+ parsed = value;
223
+ }
224
+ catch { }
225
+ }
226
+ const payment = { ...EMPTY_PAYMENT, ...parsed };
227
+ const paidAt = payment.paid_at ?? row.paid_at ?? null;
228
+ const isPaid = payment.is_paid === true || !!paidAt;
229
+ return { payment: { ...payment, paid_at: paidAt, is_paid: isPaid }, isPaid, paidAt };
230
+ }
231
+ /** True when the order has any recorded payment. */
232
+ export function isOrderPaid(order) {
233
+ if (!order)
234
+ return false;
235
+ if (order.is_paid === true || order.paid_at)
236
+ return true;
237
+ const payment = order.payment;
238
+ return payment?.is_paid === true || !!payment?.paid_at;
239
+ }
196
240
  function orderRowToSellerOrder(row) {
241
+ const { payment, isPaid, paidAt } = paymentStateOf(row);
197
242
  return {
198
243
  id: row.id,
199
244
  business_id: row.business_id,
@@ -207,7 +252,7 @@ function orderRowToSellerOrder(row) {
207
252
  total_minor: row.total_minor,
208
253
  tax_minor: row.tax_minor,
209
254
  tax_lines: row.tax_lines ? JSON.parse(row.tax_lines) : [],
210
- payment: row.payment ? JSON.parse(row.payment) : { is_paid: false, reference: null, amount_minor: null, currency: null, paid_at: null },
255
+ payment,
211
256
  customer_name: row.customer_name,
212
257
  customer_phone: row.customer_phone,
213
258
  pos_payment_method: row.pos_payment_method,
@@ -217,15 +262,15 @@ function orderRowToSellerOrder(row) {
217
262
  delivery_lat: null,
218
263
  delivery_lng: null,
219
264
  user: row.user ? JSON.parse(row.user) : null,
220
- currency: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
221
- currency_code: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
265
+ currency: payment.currency ?? "GHS",
266
+ currency_code: payment.currency ?? "GHS",
222
267
  discount_minor: 0,
223
268
  promo_code: null,
224
269
  tip_minor: null,
225
270
  delivery_fee_minor: 0,
226
271
  delivery_status: null,
227
- is_paid: row.payment ? JSON.parse(row.payment).is_paid ?? false : false,
228
- paid_at: row.payment ? JSON.parse(row.payment).paid_at ?? null : null,
272
+ is_paid: isPaid,
273
+ paid_at: paidAt,
229
274
  inserted_at: row.inserted_at,
230
275
  updated_at: row.updated_at ?? row.inserted_at,
231
276
  sync_failed_at: row.sync_failed_at ?? null,
@@ -235,16 +280,102 @@ function orderRowToSellerOrder(row) {
235
280
  export async function insertOrder(db, order) {
236
281
  const hlc = order.hlc ?? new Date().toISOString();
237
282
  const updatedAt = order.updated_at ?? order.inserted_at;
238
- await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
239
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
240
- 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, 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
241
- WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.payment), 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);
283
+ const paidAt = order.paid_at ??
284
+ order.payment?.paid_at ??
285
+ (isOrderPaid(order) ? updatedAt : null);
286
+ await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
288
+ 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
289
+ WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.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);
242
290
  notifyDbChange(order.business_id, "orders");
243
291
  }
244
292
  export async function getOrderById(db, businessId, idOrNumber) {
245
293
  const row = await db.getFirstAsync(`SELECT * FROM orders WHERE business_id = ? AND (id = ? OR order_number = ?) LIMIT 1;`, businessId, idOrNumber, idOrNumber);
246
294
  return row ? orderRowToSellerOrder(row) : null;
247
295
  }
296
+ export async function getPendingOrderIntent(db, businessId, orderId) {
297
+ const empty = { status: null, isPaid: false, hasPending: false };
298
+ try {
299
+ const rows = await db.getAllAsync(`SELECT command_type, payload FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status = 'pending' ORDER BY created_at ASC, rowid ASC;`, businessId, orderId);
300
+ if (!rows.length)
301
+ return empty;
302
+ let status = null;
303
+ let isPaid = false;
304
+ for (const row of rows) {
305
+ const commandType = row.command_type ?? "";
306
+ const target = queuedStatusOf(commandType);
307
+ if (target)
308
+ status = target;
309
+ if (commandType === "payment:confirm_cash" || commandType === "order:confirm_cash") {
310
+ isPaid = true;
311
+ }
312
+ // A cash sale is paid at the moment it is rung up — the queued create
313
+ // itself is the record of that intent. Without this, a pull landing
314
+ // before the create reaches the backend replaces the paid local row
315
+ // with the server's (older, unpaid) copy.
316
+ if (commandType === "order:create") {
317
+ try {
318
+ const payload = row.payload ? JSON.parse(row.payload) : null;
319
+ const method = payload?.pos_payment_method ?? "cash";
320
+ if (method === "cash")
321
+ isPaid = true;
322
+ }
323
+ catch { }
324
+ }
325
+ }
326
+ return { status, isPaid, hasPending: true };
327
+ }
328
+ catch {
329
+ return empty;
330
+ }
331
+ }
332
+ /**
333
+ * Write a server order into the local read model.
334
+ *
335
+ * Unlike `insertOrder` (which is HLC-guarded for local/LAN writes), the
336
+ * server copy is authoritative: it always overwrites the row. The only
337
+ * exception is an order with unsynced local work — its status and payment
338
+ * stay as the device intended until the queued command is acknowledged.
339
+ *
340
+ * The HLC is taken from the server's own `updated_at` so device clock skew
341
+ * can never make a fresh server state look stale.
342
+ */
343
+ export async function upsertServerOrder(db, order, opts = {}) {
344
+ if (!order?.id || !order?.business_id)
345
+ return;
346
+ const existing = await getOrderById(db, order.business_id, order.id);
347
+ const status = opts.preserveStatus && existing ? existing.status : order.status;
348
+ const serverPayment = (order.payment ?? {});
349
+ const serverPaidAt = order.paid_at ?? serverPayment.paid_at ?? null;
350
+ const serverIsPaid = order.is_paid === true || !!serverPaidAt || serverPayment.is_paid === true;
351
+ let payment;
352
+ if (existing &&
353
+ (opts.preservePayment || (!serverIsPaid && isOrderPaid(existing)))) {
354
+ // Keep the device's payment. A pull must never un-pay an order: the
355
+ // server copy of a cash sale rung up moments ago can lag behind, and it
356
+ // used to reset the row to UNPAID until the create finished syncing.
357
+ payment = { ...existing.payment };
358
+ }
359
+ else {
360
+ payment = {
361
+ ...serverPayment,
362
+ is_paid: serverIsPaid,
363
+ paid_at: serverPaidAt,
364
+ amount_minor: serverPayment.amount_minor ?? order.total_minor ?? null,
365
+ currency: serverPayment.currency ?? order.currency_code ?? null,
366
+ };
367
+ }
368
+ const paidAt = payment.paid_at ?? null;
369
+ const hlc = order.hlc ??
370
+ order.updated_at ??
371
+ order.inserted_at ??
372
+ new Date().toISOString();
373
+ const updatedAt = order.updated_at ?? order.inserted_at;
374
+ await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
375
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
+ notifyDbChange(order.business_id, "orders");
378
+ }
248
379
  export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
249
380
  const where = ["business_id = ?"];
250
381
  const args = [businessId];
@@ -263,11 +394,13 @@ export async function listOrders(db, businessId, filters, page = 1, pageSize = 2
263
394
  where.push("order_type = ?");
264
395
  args.push(filters.order_type);
265
396
  }
397
+ // Paid means "the order has a payment timestamp" (cash at the till) or the
398
+ // payment blob says so (gateway payment) — mirroring the backend filter.
266
399
  if (filters?.is_paid === "paid") {
267
- where.push("json_extract(payment, '$.is_paid') = 1");
400
+ where.push("(json_extract(payment, '$.is_paid') = 1 OR paid_at IS NOT NULL)");
268
401
  }
269
402
  else if (filters?.is_paid === "unpaid") {
270
- where.push("(json_extract(payment, '$.is_paid') IS NULL OR json_extract(payment, '$.is_paid') = 0)");
403
+ where.push("(COALESCE(json_extract(payment, '$.is_paid'), 0) = 0 AND paid_at IS NULL)");
271
404
  }
272
405
  if (filters?.q) {
273
406
  where.push("(order_number LIKE ? OR customer_name LIKE ? OR customer_phone LIKE ?)");
@@ -311,7 +444,7 @@ export async function updateOrderPayment(db, businessId, orderId, patch) {
311
444
  if (!row)
312
445
  return;
313
446
  const payment = { ...row.payment, is_paid: patch.is_paid, paid_at: patch.paid_at };
314
- await db.runAsync(`UPDATE orders SET payment = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, JSON.stringify(payment), new Date().toISOString(), businessId, orderId, orderId);
447
+ await db.runAsync(`UPDATE orders SET payment = ?, paid_at = COALESCE(?, paid_at), hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, JSON.stringify(payment), patch.is_paid ? patch.paid_at : null, new Date().toISOString(), businessId, orderId, orderId);
315
448
  notifyDbChange(businessId, "orders");
316
449
  }
317
450
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Business-configurable fulfilment flow for terminal / business-app orders.
3
+ *
4
+ * Buyer-placed online orders always use the full pipeline
5
+ * (accept → preparing → ready → completed). Orders rung up on a POS terminal
6
+ * or in the business app follow the business setting:
7
+ *
8
+ * "staged" (default) — same pipeline as online orders.
9
+ * "direct" — the sale is completed the moment it is taken, so the
10
+ * order is born `delivered` and no pipeline buttons are
11
+ * offered.
12
+ *
13
+ * The setting lives on the business (`businesses.pos_order_flow`) and is
14
+ * cached in the `business:profile` reference record, so it is readable while
15
+ * offline.
16
+ */
17
+ import type { SQLiteDatabase } from "../../adapters/sqlite";
18
+ export type OrderFlowMode = "staged" | "direct";
19
+ export declare const DEFAULT_ORDER_FLOW_MODE: OrderFlowMode;
20
+ /** Coerce any server/local value into a known mode. */
21
+ export declare function normalizeOrderFlowMode(value: unknown): OrderFlowMode;
22
+ export declare function isDirectOrderFlow(value: unknown): boolean;
23
+ /**
24
+ * Read `pos_order_flow` out of a `business:profile` payload. Accepts both the
25
+ * reference-data envelope (`{ data: {...} }`) and a bare profile object.
26
+ */
27
+ export declare function orderFlowModeFromProfile(payload: unknown): OrderFlowMode;
28
+ /**
29
+ * Cached business flow mode (works fully offline). Falls back to "staged" when
30
+ * the profile has never been synced — the safe, full-pipeline default.
31
+ */
32
+ export declare function getBusinessOrderFlowMode(businessId: string, db?: SQLiteDatabase): Promise<OrderFlowMode>;
33
+ /**
34
+ * Status a POS/business-app order starts in.
35
+ *
36
+ * Cash sales in direct flow are complete on creation. Anything that still
37
+ * needs money (mobile money / card) must stay `awaiting_payment` first — the
38
+ * backend moves it to `accepted` (staged) or `delivered` (direct) once the
39
+ * payment lands.
40
+ */
41
+ export declare function initialPosOrderStatus(paymentMethod: string | null | undefined, mode: OrderFlowMode): "accepted" | "awaiting_payment" | "delivered";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Business-configurable fulfilment flow for terminal / business-app orders.
3
+ *
4
+ * Buyer-placed online orders always use the full pipeline
5
+ * (accept → preparing → ready → completed). Orders rung up on a POS terminal
6
+ * or in the business app follow the business setting:
7
+ *
8
+ * "staged" (default) — same pipeline as online orders.
9
+ * "direct" — the sale is completed the moment it is taken, so the
10
+ * order is born `delivered` and no pipeline buttons are
11
+ * offered.
12
+ *
13
+ * The setting lives on the business (`businesses.pos_order_flow`) and is
14
+ * cached in the `business:profile` reference record, so it is readable while
15
+ * offline.
16
+ */
17
+ import { openSyncDatabase } from "../../db/connection";
18
+ import { getReferenceData } from "../../reference/operations";
19
+ export const DEFAULT_ORDER_FLOW_MODE = "staged";
20
+ /** Coerce any server/local value into a known mode. */
21
+ export function normalizeOrderFlowMode(value) {
22
+ return value === "direct" ? "direct" : DEFAULT_ORDER_FLOW_MODE;
23
+ }
24
+ export function isDirectOrderFlow(value) {
25
+ return normalizeOrderFlowMode(value) === "direct";
26
+ }
27
+ /**
28
+ * Read `pos_order_flow` out of a `business:profile` payload. Accepts both the
29
+ * reference-data envelope (`{ data: {...} }`) and a bare profile object.
30
+ */
31
+ export function orderFlowModeFromProfile(payload) {
32
+ if (!payload || typeof payload !== "object")
33
+ return DEFAULT_ORDER_FLOW_MODE;
34
+ const record = payload;
35
+ return normalizeOrderFlowMode(record.data?.pos_order_flow ?? record.pos_order_flow);
36
+ }
37
+ /**
38
+ * Cached business flow mode (works fully offline). Falls back to "staged" when
39
+ * the profile has never been synced — the safe, full-pipeline default.
40
+ */
41
+ export async function getBusinessOrderFlowMode(businessId, db) {
42
+ if (!businessId)
43
+ return DEFAULT_ORDER_FLOW_MODE;
44
+ try {
45
+ const connection = db ?? (await openSyncDatabase());
46
+ const record = await getReferenceData(connection, businessId, "business:profile");
47
+ return orderFlowModeFromProfile(record?.payload);
48
+ }
49
+ catch {
50
+ return DEFAULT_ORDER_FLOW_MODE;
51
+ }
52
+ }
53
+ /**
54
+ * Status a POS/business-app order starts in.
55
+ *
56
+ * Cash sales in direct flow are complete on creation. Anything that still
57
+ * needs money (mobile money / card) must stay `awaiting_payment` first — the
58
+ * backend moves it to `accepted` (staged) or `delivered` (direct) once the
59
+ * payment lands.
60
+ */
61
+ export function initialPosOrderStatus(paymentMethod, mode) {
62
+ const method = paymentMethod ?? "cash";
63
+ if (method !== "cash")
64
+ return "awaiting_payment";
65
+ return mode === "direct" ? "delivered" : "accepted";
66
+ }
@@ -15,6 +15,7 @@ import { canTransition } from "../../utils/order-transitions";
15
15
  import { TRANSITION_ERROR_CODES, TransitionError } from "../../sync/transition-errors";
16
16
  import { validateOrderCreateInput } from "../../validation/offline";
17
17
  import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
18
+ import { getBusinessOrderFlowMode, initialPosOrderStatus } from "./order-flow";
18
19
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
19
20
  import { getAuthToken } from "../../adapters/auth";
20
21
  import { canBusinessSync } from "../../sync/sync-guard";
@@ -78,6 +79,10 @@ export async function createLocalOrder(businessId, input) {
78
79
  // Offline validation mirror — backend Order.changeset rules, pre-commit.
79
80
  validateOrderCreateInput(input);
80
81
  const method = input.pos_payment_method ?? "cash";
82
+ // Business-configurable fulfilment flow (cached business profile, offline).
83
+ // "direct" completes walk-in sales the moment they are rung up; "staged"
84
+ // keeps accept → preparing → ready → completed.
85
+ const flowMode = await getBusinessOrderFlowMode(businessId);
81
86
  // Operating-hours gate (fail-closed, offline-first): matches backend
82
87
  // validate_business_open. Throws BUSINESS_CLOSED / HOURS_NOT_SYNCED before
83
88
  // anything is written, so a doomed order never enters SQLite/sync_jobs.
@@ -101,7 +106,13 @@ export async function createLocalOrder(businessId, input) {
101
106
  if (!dd)
102
107
  throw new Error("No device number leased (99 max) — connect online once to lease DD");
103
108
  const id = (input.id ?? input.command_id ?? generateUUID());
104
- const orderNumber = input.order_number?.toString() ?? generateOrderNumberForBusiness(businessId);
109
+ // Order numbers must be real strings. A non-string value used to be
110
+ // coerced with `.toString()`, which wrote "[object Object]" into the local
111
+ // DB and broke every lookup for that order.
112
+ const rawOrderNumber = input.order_number;
113
+ const orderNumber = typeof rawOrderNumber === "string" && /^[A-Za-z0-9-]{1,32}$/.test(rawOrderNumber)
114
+ ? rawOrderNumber
115
+ : generateOrderNumberForBusiness(businessId);
105
116
  const callout = parseInt(orderNumber.split("-")[2] ?? "0", 10) || 0;
106
117
  // Use client-provided timestamps for offline-first sync so records created
107
118
  // while offline retain their original creation time.
@@ -122,7 +133,7 @@ export async function createLocalOrder(businessId, input) {
122
133
  id,
123
134
  order_number: orderNumber,
124
135
  callout_number: callout,
125
- status: isPos ? "accepted" : "pending",
136
+ status: isPos ? initialPosOrderStatus(method, flowMode) : "pending",
126
137
  order_type: input.order_type ?? "takeout",
127
138
  is_pos: isPos,
128
139
  customer_name: input.customer_name ?? null,
@@ -155,7 +166,7 @@ export async function createLocalOrder(businessId, input) {
155
166
  };
156
167
  const db = await openSyncDatabase();
157
168
  await insertOrder(db, order);
158
- syncLogger.debug("DomainOrders", "createLocalOrder inserted", { businessId, id, orderNumber, isPos, connectivity: getConnectivityState(), hasToken: !!getAuthToken(), deviceNumber: dd });
169
+ syncLogger.debug("DomainOrders", "createLocalOrder inserted", { businessId, id, orderNumber, isPos, flowMode, status: order.status, connectivity: getConnectivityState(), hasToken: !!getAuthToken(), deviceNumber: dd });
159
170
  try {
160
171
  await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);`, id, businessId, "order:create", id, JSON.stringify({ ...input, order_number: orderNumber, id }), now, now);
161
172
  syncLogger.debug("DomainOrders", "sync_jobs enqueued", { businessId, id, command_type: "order:create" });
@@ -195,10 +206,19 @@ export async function transitionLocalOrder(businessId, orderId, action) {
195
206
  }
196
207
  try {
197
208
  const now = new Date().toISOString();
198
- const jobId = generateUUID();
199
- await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);`, jobId, businessId, `order:${action}`, orderId, JSON.stringify({ action, orderId }), now, now);
200
- syncLogger.debug("DomainOrders", "transition sync_jobs enqueued", { businessId, orderId, action, jobId });
201
- void drainOne(businessId, orderId).catch((e) => syncLogger.debug("DomainOrders", "drainOne after transition caught", { err: String(e) }));
209
+ const commandType = `order:${action}`;
210
+ // Coalesce duplicates: the same transition queued twice (double tap,
211
+ // replay) must not be sent to the backend twice.
212
+ const duplicate = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND command_type = ? AND status = 'pending' LIMIT 1;`, businessId, orderId, commandType);
213
+ if (!duplicate) {
214
+ const jobId = generateUUID();
215
+ await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);`, jobId, businessId, commandType, orderId, JSON.stringify({ action, orderId }), now, now);
216
+ syncLogger.debug("DomainOrders", "transition sync_jobs enqueued", { businessId, orderId, action, jobId });
217
+ void drainOne(businessId, orderId).catch((e) => syncLogger.debug("DomainOrders", "drainOne after transition caught", { err: String(e) }));
218
+ }
219
+ else {
220
+ syncLogger.debug("DomainOrders", "transition already queued — job not duplicated", { businessId, orderId, action, jobId: duplicate.id });
221
+ }
202
222
  }
203
223
  catch (e) {
204
224
  syncLogger.debug("DomainOrders", "transition enqueue fail", { err: String(e) });
@@ -215,15 +235,12 @@ export async function listLocalOrders(businessId, filters, page = 1, pageSize =
215
235
  const db = await openSyncDatabase();
216
236
  const res = await listOrders(db, businessId, filters, page, pageSize);
217
237
  if (res.total === 0) {
218
- // blank-screen: snapshot fallback already tried — if still empty and online, fetch from cloud now so listing populates without manual refresh
238
+ // Cold device (no local rows yet): fetch in the BACKGROUND. Blocking the
239
+ // render on a multi-page network pull froze the screen for seconds on a
240
+ // fresh terminal; the pull writes to SQLite and the reconciliation
241
+ // bridge refreshes the list when it lands.
219
242
  if (isOnline() && canBusinessSync(businessId)) {
220
- try {
221
- await pullRemoteOrders(businessId);
222
- }
223
- catch { }
224
- const afterPull = await listOrders(db, businessId, filters, page, pageSize);
225
- if (afterPull.total > 0)
226
- return afterPull;
243
+ void pullRemoteOrders(businessId).catch((e) => syncLogger.debug("DomainOrders", "background pull failed", { businessId, err: String(e) }));
227
244
  }
228
245
  // filtered miss — still try backfill re-read
229
246
  if (!filters || Object.keys(filters).length === 0) {
@@ -1,28 +1,35 @@
1
1
  /**
2
- * Realtime invalidation hooks — bridge Phoenix WebSocket events into the
3
- * Sync Engine so useSyncReconciliation fires and React Query caches refresh.
2
+ * Realtime invalidation hooks.
4
3
  *
5
- * Both hooks are idempotent-safe and can be called from multiple screens.
6
- * Invalidations are debounced: a reconnect/replay burst can deliver dozens
4
+ * The cloud → local event path now has a single owner: the global realtime
5
+ * bridge (`src/realtime/realtime-bridge.ts`), mounted once per app. It
6
+ * publishes every cloud event through the Sync Engine, and
7
+ * `useSyncReconciliation` reacts by journaling the event, patching the
8
+ * offline read model, invalidating the affected caches (debounced, 400 ms)
9
+ * and relaying the event to LAN peers.
10
+ *
11
+ * `useOrderRealtimeUpdates` used to be mounted per screen (POS, Orders,
12
+ * Kitchen, Display). That meant every mounted screen processed the same
13
+ * event — duplicated persistence, duplicated invalidations, duplicated
14
+ * fetches — and every screen that did NOT mount it (Dashboard, Settings)
15
+ * never saw another terminal's order at all. It is kept as a no-op so
16
+ * existing call sites keep compiling; new code must not use it.
17
+ *
18
+ * Invalidations stay coalesced: a reconnect/replay burst can deliver dozens
7
19
  * of events at once, and an immediate invalidate-per-event forces repeated
8
- * full re-renders of boards/grids — a major CPU spike source on low-power
9
- * POS hardware. Coalescing into one invalidation per ~400ms window keeps
10
- * the UI fresh without the render storm.
20
+ * full re-renders of boards/grids — a major CPU spike on low-power POS
21
+ * hardware. One invalidation per ~400 ms window keeps the UI fresh without
22
+ * the render storm.
11
23
  */
12
24
  /**
13
- * Subscribe to real-time order events and invalidate all queries under the
14
- * given root key prefix (e.g. ['seller'], ['pos'], ['kiosk']).
15
- *
16
- * Each event is also persisted to the durable offline read model so order
17
- * history and states survive a restart while offline, and published through
18
- * the Sync Engine so useSyncReconciliation + LAN relay fire without polling.
19
- *
20
- * Call once per screen that needs live updates (POS, Orders, Kitchen, etc.).
25
+ * @deprecated Mount the global bridge once at the app root instead
26
+ * (`useRealtimeInvalidation` or `useSyncReconciliation`). Kept as a no-op
27
+ * so legacy call sites remain source-compatible.
21
28
  */
22
- export declare function useOrderRealtimeUpdates(rootKey: readonly string[]): void;
29
+ export declare function useOrderRealtimeUpdates(_rootKey: readonly string[]): void;
23
30
  /**
24
- * Global realtime invalidation — currently handled entirely by
25
- * useOrderRealtimeUpdates + useSyncReconciliation. Kept as a no-op for
26
- * backward compatibility with any lingering call sites.
31
+ * Global realtime invalidation — mounts the single app-wide Phoenix
32
+ * subscription. Pair with `useSyncReconciliation` (which reacts to the
33
+ * published events) at the app root.
27
34
  */
28
35
  export declare function useRealtimeInvalidation(): void;