@teincfood/core 0.9.24 → 0.9.26

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.
@@ -73,9 +73,14 @@ export function useMenuItemsQuery(filters, enabled = true) {
73
73
  ? networkQuery.data.pages.flatMap((p) => p.data)
74
74
  : undefined;
75
75
  const data = isOnline && networkData !== undefined ? networkData : enabled ? filtered : undefined;
76
+ // A local snapshot (even an empty list) means the POS has something to
77
+ // render: never gate the whole screen on the in-flight network refresh.
78
+ const hasLocalSnapshot = cachedQuery.data != null;
79
+ const pending = cachedQuery.isPending || (!hasLocalSnapshot && networkQuery.isFetching);
76
80
  return {
77
81
  data,
78
- isPending: cachedQuery.isPending && !networkData,
82
+ isPending: pending,
83
+ isLoading: pending,
79
84
  isFetching: networkQuery.isFetching,
80
85
  isError: cachedQuery.isError || networkQuery.isError,
81
86
  error: (cachedQuery.error ?? networkQuery.error),
@@ -117,10 +122,14 @@ export function useMenuCategoriesQuery() {
117
122
  const usingNetwork = isOnline && networkQuery.data;
118
123
  const data = usingNetwork ? networkQuery.data : cachedQuery.data;
119
124
  const hasCache = cachedQuery.data != null;
125
+ // Only report "loading" when there is nothing local to show; the network
126
+ // refresh must never hide an already-cached category list.
127
+ const pending = cachedQuery.isPending || (!hasCache && networkQuery.isFetching);
120
128
  return {
121
129
  ...networkQuery,
122
130
  data,
123
- isPending: cachedQuery.isPending && !networkQuery.data,
131
+ isPending: pending,
132
+ isLoading: pending,
124
133
  isError: hasCache ? false : networkQuery.isError,
125
134
  error: hasCache ? null : networkQuery.error,
126
135
  };
@@ -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,10 +66,17 @@ 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>;
@@ -198,7 +198,47 @@ function rowToDomainEvent(row) {
198
198
  timestamp: row.timestamp,
199
199
  };
200
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
+ }
201
240
  function orderRowToSellerOrder(row) {
241
+ const { payment, isPaid, paidAt } = paymentStateOf(row);
202
242
  return {
203
243
  id: row.id,
204
244
  business_id: row.business_id,
@@ -212,7 +252,7 @@ function orderRowToSellerOrder(row) {
212
252
  total_minor: row.total_minor,
213
253
  tax_minor: row.tax_minor,
214
254
  tax_lines: row.tax_lines ? JSON.parse(row.tax_lines) : [],
215
- payment: row.payment ? JSON.parse(row.payment) : { is_paid: false, reference: null, amount_minor: null, currency: null, paid_at: null },
255
+ payment,
216
256
  customer_name: row.customer_name,
217
257
  customer_phone: row.customer_phone,
218
258
  pos_payment_method: row.pos_payment_method,
@@ -222,15 +262,15 @@ function orderRowToSellerOrder(row) {
222
262
  delivery_lat: null,
223
263
  delivery_lng: null,
224
264
  user: row.user ? JSON.parse(row.user) : null,
225
- currency: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
226
- currency_code: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
265
+ currency: payment.currency ?? "GHS",
266
+ currency_code: payment.currency ?? "GHS",
227
267
  discount_minor: 0,
228
268
  promo_code: null,
229
269
  tip_minor: null,
230
270
  delivery_fee_minor: 0,
231
271
  delivery_status: null,
232
- is_paid: row.payment ? JSON.parse(row.payment).is_paid ?? false : false,
233
- paid_at: row.payment ? JSON.parse(row.payment).paid_at ?? null : null,
272
+ is_paid: isPaid,
273
+ paid_at: paidAt,
234
274
  inserted_at: row.inserted_at,
235
275
  updated_at: row.updated_at ?? row.inserted_at,
236
276
  sync_failed_at: row.sync_failed_at ?? null,
@@ -240,10 +280,13 @@ function orderRowToSellerOrder(row) {
240
280
  export async function insertOrder(db, order) {
241
281
  const hlc = order.hlc ?? new Date().toISOString();
242
282
  const updatedAt = order.updated_at ?? order.inserted_at;
243
- 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)
244
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
245
- 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
246
- 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);
247
290
  notifyDbChange(order.business_id, "orders");
248
291
  }
249
292
  export async function getOrderById(db, businessId, idOrNumber) {
@@ -253,7 +296,7 @@ export async function getOrderById(db, businessId, idOrNumber) {
253
296
  export async function getPendingOrderIntent(db, businessId, orderId) {
254
297
  const empty = { status: null, isPaid: false, hasPending: false };
255
298
  try {
256
- const rows = await db.getAllAsync(`SELECT command_type FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status = 'pending' ORDER BY created_at ASC, rowid ASC;`, businessId, orderId);
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);
257
300
  if (!rows.length)
258
301
  return empty;
259
302
  let status = null;
@@ -266,6 +309,19 @@ export async function getPendingOrderIntent(db, businessId, orderId) {
266
309
  if (commandType === "payment:confirm_cash" || commandType === "order:confirm_cash") {
267
310
  isPaid = true;
268
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
+ }
269
325
  }
270
326
  return { status, isPaid, hasPending: true };
271
327
  }
@@ -289,15 +345,35 @@ export async function upsertServerOrder(db, order, opts = {}) {
289
345
  return;
290
346
  const existing = await getOrderById(db, order.business_id, order.id);
291
347
  const status = opts.preserveStatus && existing ? existing.status : order.status;
292
- const payment = opts.preservePayment && existing ? existing.payment : order.payment;
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;
293
369
  const hlc = order.hlc ??
294
370
  order.updated_at ??
295
371
  order.inserted_at ??
296
372
  new Date().toISOString();
297
373
  const updatedAt = order.updated_at ?? order.inserted_at;
298
- 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)
299
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
300
- 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;`, 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), 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);
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);
301
377
  notifyDbChange(order.business_id, "orders");
302
378
  }
303
379
  export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
@@ -318,11 +394,13 @@ export async function listOrders(db, businessId, filters, page = 1, pageSize = 2
318
394
  where.push("order_type = ?");
319
395
  args.push(filters.order_type);
320
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.
321
399
  if (filters?.is_paid === "paid") {
322
- where.push("json_extract(payment, '$.is_paid') = 1");
400
+ where.push("(json_extract(payment, '$.is_paid') = 1 OR paid_at IS NOT NULL)");
323
401
  }
324
402
  else if (filters?.is_paid === "unpaid") {
325
- 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)");
326
404
  }
327
405
  if (filters?.q) {
328
406
  where.push("(order_number LIKE ? OR customer_name LIKE ? OR customer_phone LIKE ?)");
@@ -366,7 +444,7 @@ export async function updateOrderPayment(db, businessId, orderId, patch) {
366
444
  if (!row)
367
445
  return;
368
446
  const payment = { ...row.payment, is_paid: patch.is_paid, paid_at: patch.paid_at };
369
- 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);
370
448
  notifyDbChange(businessId, "orders");
371
449
  }
372
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
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Local Orders Service — SQLite is real DB (like backend Orders).
3
3
  * Device-partitioned by UUID PK + DD-RRR-CCC per business.
4
- * is_pos orders are accepted at create; online payment requires online.
4
+ * POS orders start in the business's configured flow (staged → accepted,
5
+ * direct → delivered); online payment requires connectivity.
5
6
  */
6
7
  import type { SellerOrder } from "../../types/api/seller.api";
7
8
  import type { POSOrderCreateRequest } from "../../types/pos.types";
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Local Orders Service — SQLite is real DB (like backend Orders).
3
3
  * Device-partitioned by UUID PK + DD-RRR-CCC per business.
4
- * is_pos orders are accepted at create; online payment requires online.
4
+ * POS orders start in the business's configured flow (staged → accepted,
5
+ * direct → delivered); online payment requires connectivity.
5
6
  */
6
7
  import { openSyncDatabase } from "../../db/connection";
7
8
  import { insertOrder, getOrderById, listOrders, updateOrderStatus, updateOrderPayment, getEntitySnapshotsByType } from "../../db/operations";
@@ -15,6 +16,7 @@ import { canTransition } from "../../utils/order-transitions";
15
16
  import { TRANSITION_ERROR_CODES, TransitionError } from "../../sync/transition-errors";
16
17
  import { validateOrderCreateInput } from "../../validation/offline";
17
18
  import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
19
+ import { getBusinessOrderFlowMode, initialPosOrderStatus } from "./order-flow";
18
20
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
19
21
  import { getAuthToken } from "../../adapters/auth";
20
22
  import { canBusinessSync } from "../../sync/sync-guard";
@@ -78,6 +80,10 @@ export async function createLocalOrder(businessId, input) {
78
80
  // Offline validation mirror — backend Order.changeset rules, pre-commit.
79
81
  validateOrderCreateInput(input);
80
82
  const method = input.pos_payment_method ?? "cash";
83
+ // Business-configurable fulfilment flow (cached business profile, offline).
84
+ // "direct" completes walk-in sales the moment they are rung up; "staged"
85
+ // keeps accept → preparing → ready → completed.
86
+ const flowMode = await getBusinessOrderFlowMode(businessId);
81
87
  // Operating-hours gate (fail-closed, offline-first): matches backend
82
88
  // validate_business_open. Throws BUSINESS_CLOSED / HOURS_NOT_SYNCED before
83
89
  // anything is written, so a doomed order never enters SQLite/sync_jobs.
@@ -128,7 +134,7 @@ export async function createLocalOrder(businessId, input) {
128
134
  id,
129
135
  order_number: orderNumber,
130
136
  callout_number: callout,
131
- status: isPos ? "accepted" : "pending",
137
+ status: isPos ? initialPosOrderStatus(method, flowMode) : "pending",
132
138
  order_type: input.order_type ?? "takeout",
133
139
  is_pos: isPos,
134
140
  customer_name: input.customer_name ?? null,
@@ -161,7 +167,7 @@ export async function createLocalOrder(businessId, input) {
161
167
  };
162
168
  const db = await openSyncDatabase();
163
169
  await insertOrder(db, order);
164
- syncLogger.debug("DomainOrders", "createLocalOrder inserted", { businessId, id, orderNumber, isPos, connectivity: getConnectivityState(), hasToken: !!getAuthToken(), deviceNumber: dd });
170
+ syncLogger.debug("DomainOrders", "createLocalOrder inserted", { businessId, id, orderNumber, isPos, flowMode, status: order.status, connectivity: getConnectivityState(), hasToken: !!getAuthToken(), deviceNumber: dd });
165
171
  try {
166
172
  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);
167
173
  syncLogger.debug("DomainOrders", "sync_jobs enqueued", { businessId, id, command_type: "order:create" });
@@ -36,10 +36,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
36
36
  isRefetching: boolean;
37
37
  isStale: boolean;
38
38
  isEnabled: boolean;
39
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
40
- fetchStatus: import("@tanstack/query-core").FetchStatus;
41
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
42
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
39
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
40
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
41
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
42
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
43
43
  hasNextPage: boolean;
44
44
  hasPreviousPage: boolean;
45
45
  isFetchingNextPage: boolean;
@@ -70,10 +70,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
70
70
  isRefetching: boolean;
71
71
  isStale: boolean;
72
72
  isEnabled: boolean;
73
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
74
- fetchStatus: import("@tanstack/query-core").FetchStatus;
75
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
76
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
73
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
74
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
75
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
76
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
77
77
  hasNextPage: boolean;
78
78
  hasPreviousPage: boolean;
79
79
  isFetchingNextPage: boolean;
@@ -105,10 +105,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
105
105
  isRefetching: boolean;
106
106
  isStale: boolean;
107
107
  isEnabled: boolean;
108
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
109
- fetchStatus: import("@tanstack/query-core").FetchStatus;
110
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
111
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
108
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
109
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
110
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
111
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
112
112
  hasNextPage: boolean;
113
113
  hasPreviousPage: boolean;
114
114
  isFetchingNextPage: boolean;
@@ -138,10 +138,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
138
138
  isRefetching: boolean;
139
139
  isStale: boolean;
140
140
  isEnabled: boolean;
141
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
142
- fetchStatus: import("@tanstack/query-core").FetchStatus;
143
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
144
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
141
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
142
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
143
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
144
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
145
145
  hasNextPage: boolean;
146
146
  hasPreviousPage: boolean;
147
147
  isFetchingNextPage: boolean;
@@ -172,10 +172,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
172
172
  isRefetching: boolean;
173
173
  isStale: boolean;
174
174
  isEnabled: boolean;
175
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
176
- fetchStatus: import("@tanstack/query-core").FetchStatus;
177
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
178
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
175
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
176
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
177
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
178
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
179
179
  hasNextPage: boolean;
180
180
  hasPreviousPage: boolean;
181
181
  isFetchNextPageError: boolean;
@@ -206,10 +206,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
206
206
  isRefetching: boolean;
207
207
  isStale: boolean;
208
208
  isEnabled: boolean;
209
- refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
210
- fetchStatus: import("@tanstack/query-core").FetchStatus;
211
- fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
212
- fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
209
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
210
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
211
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
212
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
213
213
  hasNextPage: boolean;
214
214
  hasPreviousPage: boolean;
215
215
  isFetchingNextPage: boolean;