@teincfood/core 0.9.18 → 0.9.20

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.
@@ -408,4 +408,19 @@ export const MIGRATIONS = [
408
408
  `);
409
409
  },
410
410
  },
411
+ {
412
+ version: 11,
413
+ up: async (db) => {
414
+ // Terminal sync failure marker: set when a queued order:create or a
415
+ // transition dead-letters (e.g. the parent order was never created, or a
416
+ // transition 404s because the order does not exist server-side). The row
417
+ // is retained for audit instead of being silently dropped.
418
+ await db.execAsync(`
419
+ ALTER TABLE orders ADD COLUMN sync_failed_at TEXT;
420
+ `);
421
+ await db.execAsync(`
422
+ ALTER TABLE orders ADD COLUMN sync_error TEXT;
423
+ `);
424
+ },
425
+ },
411
426
  ];
@@ -67,6 +67,8 @@ export interface OrderRow {
67
67
  updated_at: string | null;
68
68
  hlc: string | null;
69
69
  deleted_at: string | null;
70
+ sync_failed_at?: string | null;
71
+ sync_error?: string | null;
70
72
  }
71
73
  export declare function insertOrder(db: SQLiteDatabase, order: import("../types/api/seller.api").SellerOrder & {
72
74
  hlc?: string;
@@ -79,11 +81,18 @@ export declare function listOrders(db: SQLiteDatabase, businessId: string, filte
79
81
  order_type?: string;
80
82
  date_from?: string;
81
83
  date_to?: string;
84
+ is_paid?: string;
82
85
  }, page?: number, pageSize?: number): Promise<{
83
86
  data: import("../types/api/seller.api").SellerOrder[];
84
87
  total: number;
85
88
  }>;
86
89
  export declare function updateOrderStatus(db: SQLiteDatabase, businessId: string, orderId: string, toStatus: string, hlc?: string): Promise<void>;
90
+ /**
91
+ * Flag an order whose queued work dead-lettered (parent create failed, or a
92
+ * transition 404'd because the server has no such order). The row is kept for
93
+ * audit rather than deleted; a later pull of the real order clears the flag.
94
+ */
95
+ export declare function markOrderSyncFailed(db: SQLiteDatabase, businessId: string, orderId: string, message: string): Promise<void>;
87
96
  export declare function updateOrderPayment(db: SQLiteDatabase, businessId: string, orderId: string, patch: {
88
97
  is_paid: boolean;
89
98
  paid_at: string | null;
@@ -228,6 +228,8 @@ function orderRowToSellerOrder(row) {
228
228
  paid_at: row.payment ? JSON.parse(row.payment).paid_at ?? null : null,
229
229
  inserted_at: row.inserted_at,
230
230
  updated_at: row.updated_at ?? row.inserted_at,
231
+ sync_failed_at: row.sync_failed_at ?? null,
232
+ sync_error: row.sync_error ?? null,
231
233
  };
232
234
  }
233
235
  export async function insertOrder(db, order) {
@@ -235,7 +237,7 @@ export async function insertOrder(db, order) {
235
237
  const updatedAt = order.updated_at ?? order.inserted_at;
236
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)
237
239
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
238
- 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
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
239
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);
240
242
  notifyDbChange(order.business_id, "orders");
241
243
  }
@@ -261,6 +263,12 @@ export async function listOrders(db, businessId, filters, page = 1, pageSize = 2
261
263
  where.push("order_type = ?");
262
264
  args.push(filters.order_type);
263
265
  }
266
+ if (filters?.is_paid === "paid") {
267
+ where.push("json_extract(payment, '$.is_paid') = 1");
268
+ }
269
+ else if (filters?.is_paid === "unpaid") {
270
+ where.push("(json_extract(payment, '$.is_paid') IS NULL OR json_extract(payment, '$.is_paid') = 0)");
271
+ }
264
272
  if (filters?.q) {
265
273
  where.push("(order_number LIKE ? OR customer_name LIKE ? OR customer_phone LIKE ?)");
266
274
  const q = `%${filters.q}%`;
@@ -286,6 +294,18 @@ export async function updateOrderStatus(db, businessId, orderId, toStatus, hlc)
286
294
  await db.runAsync(`UPDATE orders SET status = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, toStatus, now, businessId, orderId, orderId);
287
295
  notifyDbChange(businessId, "orders");
288
296
  }
297
+ /**
298
+ * Flag an order whose queued work dead-lettered (parent create failed, or a
299
+ * transition 404'd because the server has no such order). The row is kept for
300
+ * audit rather than deleted; a later pull of the real order clears the flag.
301
+ */
302
+ export async function markOrderSyncFailed(db, businessId, orderId, message) {
303
+ try {
304
+ await db.runAsync(`UPDATE orders SET sync_failed_at = ?, sync_error = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, new Date().toISOString(), message, businessId, orderId, orderId);
305
+ notifyDbChange(businessId, "orders");
306
+ }
307
+ catch { }
308
+ }
289
309
  export async function updateOrderPayment(db, businessId, orderId, patch) {
290
310
  const row = await getOrderById(db, businessId, orderId);
291
311
  if (!row)
@@ -17,6 +17,7 @@ export declare function listLocalOrders(businessId: string, filters?: {
17
17
  order_type?: string;
18
18
  date_from?: string;
19
19
  date_to?: string;
20
+ is_paid?: string;
20
21
  }, page?: number, pageSize?: number): Promise<{
21
22
  data: import("../../types/api/seller.api").SellerOrder[];
22
23
  total: number;
@@ -17,6 +17,10 @@ export declare function useDeliveryDetailQuery(deliveryId: string, enabled?: boo
17
17
  export interface DeliveryFilters {
18
18
  status?: string;
19
19
  search?: string;
20
+ order_type?: string;
21
+ is_paid?: string;
22
+ date_from?: string;
23
+ date_to?: string;
20
24
  }
21
25
  export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?: DeliveryFilters): {
22
26
  dataUpdatedAt: number;
@@ -34,7 +34,14 @@ function useBusinessId() {
34
34
  return id ?? undefined;
35
35
  }
36
36
  async function fetchOfflineDeliveries(businessId, filters, page, pageSize) {
37
- const { data } = await listLocalOrders(businessId, { status: undefined, q: filters?.search }, page, pageSize);
37
+ const { data } = await listLocalOrders(businessId, {
38
+ status: undefined,
39
+ q: filters?.search,
40
+ order_type: filters?.order_type,
41
+ is_paid: filters?.is_paid,
42
+ date_from: filters?.date_from,
43
+ date_to: filters?.date_to,
44
+ }, page, pageSize);
38
45
  const deliveryOrders = data.filter((o) => o.delivery_id);
39
46
  const statusSet = new Set((filters?.status ?? "").split(",").map((s) => s.trim()).filter(Boolean));
40
47
  const filtered = statusSet.size > 0
@@ -73,6 +80,14 @@ export function useBusinessDeliveriesQuery(enabled = true, filters) {
73
80
  params.status = filters.status;
74
81
  if (filters?.search)
75
82
  params.search = filters.search;
83
+ if (filters?.order_type)
84
+ params.order_type = filters.order_type;
85
+ if (filters?.is_paid)
86
+ params.payment_status = filters.is_paid;
87
+ if (filters?.date_from)
88
+ params.date_from = filters.date_from;
89
+ if (filters?.date_to)
90
+ params.date_to = filters.date_to;
76
91
  const res = (await deliveryService.fetchBusinessDeliveries(businessId, params));
77
92
  return res;
78
93
  },
@@ -12,11 +12,16 @@ export declare const SELLER_KEYS: {
12
12
  order: (id: string, bid?: string) => readonly ["seller", "order", string, string | undefined];
13
13
  };
14
14
  export declare function usePOSOrdersQuery(filters?: Record<string, string>, enabled?: boolean): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
15
- export declare function useSellerOrdersQuery(enabled?: boolean, filters?: {
15
+ export interface SellerOrderFilters {
16
16
  status?: string;
17
17
  q?: string;
18
18
  search?: string;
19
- }): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
19
+ order_type?: string;
20
+ is_paid?: string;
21
+ date_from?: string;
22
+ date_to?: string;
23
+ }
24
+ export declare function useSellerOrdersQuery(enabled?: boolean, filters?: SellerOrderFilters): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
20
25
  export declare function useSellerOrderQuery(orderId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
21
26
  data: SellerOrder;
22
27
  }, Error>;
@@ -58,7 +58,14 @@ export function useSellerOrdersQuery(enabled = true, filters) {
58
58
  if (!bid)
59
59
  return { data: [], pagination: { page: 1, page_size: 20, total_pages: 1, total_entries: 0 } };
60
60
  const q = filters?.q ?? filters?.search;
61
- const { data, total } = await listLocalOrders(bid, { status: filters?.status, q }, pageParam, 20);
61
+ const { data, total } = await listLocalOrders(bid, {
62
+ status: filters?.status,
63
+ q,
64
+ order_type: filters?.order_type,
65
+ is_paid: filters?.is_paid,
66
+ date_from: filters?.date_from,
67
+ date_to: filters?.date_to,
68
+ }, pageParam, 20);
62
69
  const merged = await mergePendingIntoList(data, bid, pageParam);
63
70
  const totalPages = Math.max(1, Math.ceil(total / 20));
64
71
  return { data: merged, pagination: { page: pageParam, page_size: 20, total_pages: totalPages, total_entries: total + (merged.length - data.length) } };
package/dist/index.d.ts CHANGED
@@ -21,7 +21,9 @@ export * from "./utils/attempt-stats";
21
21
  export { PHONE_COUNTRIES, getDialCodeFromCountry, getCountryOption, normalizePhoneForCountry, isLikelyE164 } from "./utils/phone";
22
22
  export type { PhoneCountryOption } from "./utils/phone";
23
23
  export * from "./utils/formatCurrency";
24
- export { formatDate, formatRelativeTime } from "./utils/formatDate";
24
+ export { formatDate, formatDateTime, formatTime, formatDateCompact, formatRelativeTime } from "./utils/formatDate";
25
+ export { resolveDateRange, DATE_PRESET_LABELS } from "./utils/date-range";
26
+ export type { DatePreset, DateRange } from "./utils/date-range";
25
27
  export * from "./utils/validation";
26
28
  export * from "./validation/offline";
27
29
  export * from "./utils/copyToClipBoard";
package/dist/index.js CHANGED
@@ -22,7 +22,8 @@ export * from "./utils/debug-log";
22
22
  export * from "./utils/attempt-stats";
23
23
  export { PHONE_COUNTRIES, getDialCodeFromCountry, getCountryOption, normalizePhoneForCountry, isLikelyE164 } from "./utils/phone";
24
24
  export * from "./utils/formatCurrency";
25
- export { formatDate, formatRelativeTime } from "./utils/formatDate";
25
+ export { formatDate, formatDateTime, formatTime, formatDateCompact, formatRelativeTime } from "./utils/formatDate";
26
+ export { resolveDateRange, DATE_PRESET_LABELS } from "./utils/date-range";
26
27
  export * from "./utils/validation";
27
28
  export * from "./validation/offline";
28
29
  export * from "./utils/copyToClipBoard";
@@ -15,6 +15,18 @@ declare class CommandQueueService {
15
15
  private replaying;
16
16
  private syncEngine;
17
17
  setSyncEngine(engine: SyncEngine): void;
18
+ /**
19
+ * Create-before-transition gate.
20
+ *
21
+ * A transition can only be applied to an order that exists server-side. If
22
+ * the `order:create` for the same entity_id is still queued, the transition
23
+ * must wait behind it (never POST first — that is a guaranteed 404). If the
24
+ * create dead-lettered, the transition can never succeed and must be failed
25
+ * too. A missing create row means it already synced (rows are deleted on
26
+ * success), so the transition may proceed.
27
+ */
28
+ orderCreateGate(businessId: string, entityId: string): Promise<"none" | "pending" | "failed">;
29
+ private flagOrderSyncFailed;
18
30
  /**
19
31
  * Enqueue a command because no transport is currently available.
20
32
  * Returns an optimistic domain event so the caller can update the UI.
@@ -32,6 +44,11 @@ declare class CommandQueueService {
32
44
  * permanent-rejection paths.
33
45
  */
34
46
  private failPermanently;
47
+ /**
48
+ * Dead-letter every pending transition for an entity whose parent
49
+ * order:create permanently failed.
50
+ */
51
+ private failDependentTransitions;
35
52
  /**
36
53
  * Already-applied absorb: pull server truth, and if the order is already
37
54
  * at this command's target status, delete the command as success. The
@@ -9,10 +9,11 @@
9
9
  * - Provide optimistic domain events so the UI can render pending orders.
10
10
  */
11
11
  import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
- import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
12
+ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, markOrderSyncFailed, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { TransitionError, isReplayDeferred, isReplayNoTransport, TRANSITION_ERROR_CODES } from "./transition-errors";
16
+ import { isOrderCreate, isOrderTransition } from "./transition-endpoints";
16
17
  import { assertBusinessOpenForCreate } from "../utils/business-hours";
17
18
  import { extractApiErrorCode } from "../utils/error";
18
19
  import { generateEventId } from "../utils/uuid";
@@ -198,6 +199,51 @@ class CommandQueueService {
198
199
  }
199
200
  catch { }
200
201
  }
202
+ /**
203
+ * Create-before-transition gate.
204
+ *
205
+ * A transition can only be applied to an order that exists server-side. If
206
+ * the `order:create` for the same entity_id is still queued, the transition
207
+ * must wait behind it (never POST first — that is a guaranteed 404). If the
208
+ * create dead-lettered, the transition can never succeed and must be failed
209
+ * too. A missing create row means it already synced (rows are deleted on
210
+ * success), so the transition may proceed.
211
+ */
212
+ async orderCreateGate(businessId, entityId) {
213
+ try {
214
+ const db = getSyncDatabase();
215
+ const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER, true);
216
+ let pending = false;
217
+ let failed = false;
218
+ for (const c of rows) {
219
+ if (c.business_id === businessId &&
220
+ c.entity_id === entityId &&
221
+ c.command_type === "order:create") {
222
+ if (c.status === "pending" || c.status === "in_flight")
223
+ pending = true;
224
+ else if (c.status === "failed")
225
+ failed = true;
226
+ }
227
+ }
228
+ if (pending)
229
+ return "pending";
230
+ if (failed)
231
+ return "failed";
232
+ return "none";
233
+ }
234
+ catch {
235
+ // Fail open: never brick a terminal because the DB read failed.
236
+ return "none";
237
+ }
238
+ }
239
+ async flagOrderSyncFailed(command, message) {
240
+ if (!command.entity_id)
241
+ return;
242
+ try {
243
+ await markOrderSyncFailed(getSyncDatabase(), command.business_id, command.entity_id, message);
244
+ }
245
+ catch { }
246
+ }
201
247
  /**
202
248
  * Enqueue a command because no transport is currently available.
203
249
  * Returns an optimistic domain event so the caller can update the UI.
@@ -263,6 +309,23 @@ class CommandQueueService {
263
309
  timestamp: command.timestamp,
264
310
  payload: command.payload,
265
311
  };
312
+ // Create-before-transition: never replay a transition whose parent
313
+ // order:create is still queued (it would 404), and fail it outright if
314
+ // the create already dead-lettered (it can never succeed).
315
+ if (isOrderTransition(command.command_type) && command.entity_id) {
316
+ const gate = await this.orderCreateGate(command.business_id, command.entity_id);
317
+ if (gate === "failed") {
318
+ await this.flagOrderSyncFailed(command, "Parent order creation failed");
319
+ await this.failPermanently(db, command, "Parent order creation failed", "parent-create-failed", {
320
+ code: "PARENT_CREATE_FAILED",
321
+ attempts: command.retry_count,
322
+ });
323
+ return;
324
+ }
325
+ if (gate === "pending") {
326
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "Parent order:create still queued — deferring transition");
327
+ }
328
+ }
266
329
  if (command.retry_count >= MAX_ATTEMPTS) {
267
330
  await updateCommandStatus(db, command.command_id, "failed", "Max retry count exceeded");
268
331
  return;
@@ -385,6 +448,19 @@ class CommandQueueService {
385
448
  }
386
449
  catch { }
387
450
  }
451
+ // Transition 404: the server has no such order. The absorb above already
452
+ // checked the duplicate-tap / replayed-race case; if it did not apply,
453
+ // replaying can never create the order — fail fast instead of burning
454
+ // the bounded 4xx retry budget on a resource that is gone.
455
+ if (status === 404 && isOrderTransition(command.command_type)) {
456
+ await this.flagOrderSyncFailed(command, `Order not found on server (${message})`);
457
+ await this.failPermanently(db, command, message, "404-order-missing", {
458
+ status,
459
+ code,
460
+ attempts: command.retry_count + 1,
461
+ });
462
+ return;
463
+ }
388
464
  // Every other failure is retryable on the shared 2s→5min backoff.
389
465
  //
390
466
  // 4xx (validation/permission/plan races) is bounded: a device stuck in
@@ -462,6 +538,52 @@ class CommandQueueService {
462
538
  }
463
539
  }
464
540
  catch { }
541
+ try {
542
+ if (isOrderTransition(command.command_type) || isOrderCreate(command.command_type)) {
543
+ await this.flagOrderSyncFailed(command, message);
544
+ }
545
+ }
546
+ catch { }
547
+ // A failed order:create strands every transition queued behind it — fail
548
+ // them too (they can never apply) instead of replaying them into a 404.
549
+ if (isOrderCreate(command.command_type) && command.entity_id) {
550
+ await this.failDependentTransitions(db, command, message);
551
+ }
552
+ }
553
+ /**
554
+ * Dead-letter every pending transition for an entity whose parent
555
+ * order:create permanently failed.
556
+ */
557
+ async failDependentTransitions(db, createCommand, message) {
558
+ try {
559
+ const pending = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
560
+ const rows = pending.filter((c) => c.business_id === createCommand.business_id &&
561
+ c.entity_id === createCommand.entity_id &&
562
+ c.command_type !== "order:create");
563
+ if (!rows.length)
564
+ return;
565
+ for (const row of rows) {
566
+ await updateCommandStatus(db, row.command_id, "failed", `parent_create_failed:${message}`);
567
+ reportSyncFailure({
568
+ phase: "outbox",
569
+ business_id: createCommand.business_id,
570
+ user_id: createCommand.user_id,
571
+ device_id: createCommand.device_id,
572
+ command_type: row.command_type,
573
+ command_id: row.command_id,
574
+ entity_id: createCommand.entity_id,
575
+ code: "PARENT_CREATE_FAILED",
576
+ message,
577
+ attempts: 0,
578
+ terminal: true,
579
+ });
580
+ }
581
+ syncLogger.warn("CommandQueueService", "Dead-lettered transitions behind failed create", {
582
+ entity_id: createCommand.entity_id,
583
+ count: rows.length,
584
+ });
585
+ }
586
+ catch { }
465
587
  }
466
588
  /**
467
589
  * Already-applied absorb: pull server truth, and if the order is already
@@ -24,6 +24,7 @@ import { getReferenceData } from "../reference/operations";
24
24
  import { openSyncDatabase } from "../db/connection";
25
25
  import { canBusinessSync } from "./sync-guard";
26
26
  import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
27
+ import { isOrderTransition } from "./transition-endpoints";
27
28
  import { assertEntitledForCommand } from "../reference/entitlement-guard";
28
29
  import { noteRateLimited, extractRetryAfterMs } from "./rate-limit";
29
30
  function withTimeout(promise, ms) {
@@ -74,6 +75,30 @@ export class SyncEngine {
74
75
  throw new TransitionError(TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS, "Sync requires active business and login — business_id missing or not authenticated");
75
76
  }
76
77
  syncLogger.command(command, "issued");
78
+ // Create-before-transition: a transition can only apply to an order that
79
+ // exists server-side. If its order:create is still queued, queue the
80
+ // transition behind it; if the create dead-lettered, discard the
81
+ // transition. POSTing a transition ahead of (or without) its create is a
82
+ // guaranteed 404 and must never happen.
83
+ if (isOrderTransition(command.command_type) && command.entity_id) {
84
+ const gate = await commandQueueService.orderCreateGate(command.business_id, command.entity_id);
85
+ if (gate === "failed") {
86
+ syncLogger.warn("SyncEngine", "Transition discarded — parent order:create failed", {
87
+ command_id: command.command_id,
88
+ command_type: command.command_type,
89
+ entity_id: command.entity_id,
90
+ });
91
+ throw new TransitionError(TRANSITION_ERROR_CODES.ORDER_NOT_FOUND, "Parent order creation failed — transition discarded");
92
+ }
93
+ if (gate === "pending") {
94
+ if (opts?.forReplay) {
95
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "Parent order:create still queued — deferring transition");
96
+ }
97
+ const event = await commandQueueService.enqueue(command);
98
+ this.broadcast(event);
99
+ return [event];
100
+ }
101
+ }
77
102
  // Perms guard (offline-capable): block before routing
78
103
  const required = CAPABILITY_BY_COMMAND[command.command_type];
79
104
  if (required) {
@@ -4,7 +4,7 @@
4
4
  * Core-only, single in_flight per business, coalesced via isOnline gate.
5
5
  */
6
6
  import { openSyncDatabase } from "../db/connection";
7
- import { insertOrder, getOrderById } from "../db/operations";
7
+ import { insertOrder, getOrderById, markOrderSyncFailed } from "../db/operations";
8
8
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
9
9
  import { getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
10
10
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
@@ -15,7 +15,7 @@ import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { BackoffTracker } from "./backoff";
16
16
  import { generateOrderNumber } from "./order-number";
17
17
  import { orderSnapshotsService } from "./order-snapshots.service";
18
- import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
18
+ import { getTransitionPath, DELIVERY_COMMANDS, isOrderTransition, isOrderCreate } from "./transition-endpoints";
19
19
  import { getQueuedStatus } from "../utils/order-transitions";
20
20
  import { extractApiErrorCode } from "../utils/error";
21
21
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
@@ -294,6 +294,52 @@ async function absorbSyncJobIfApplied(db, businessId, entityId, commandType, job
294
294
  }
295
295
  return false;
296
296
  }
297
+ /**
298
+ * State of the order:create sync_job for an entity, used to enforce
299
+ * create-before-transition. A missing row means the create already synced
300
+ * (rows are retained as 'completed'/'failed', so 'none' means it never
301
+ * existed — a cloud-pulled order — and the transition may proceed).
302
+ */
303
+ async function syncJobCreateState(db, businessId, entityId) {
304
+ try {
305
+ const rows = await db.getAllAsync(`SELECT * FROM sync_jobs WHERE business_id = ?;`, businessId);
306
+ const creates = rows.filter((r) => r.entity_id === entityId && r.command_type === "order:create");
307
+ if (creates.some((r) => r.status === "pending"))
308
+ return "pending";
309
+ if (creates.some((r) => r.status === "failed"))
310
+ return "failed";
311
+ return "none";
312
+ }
313
+ catch {
314
+ return "none";
315
+ }
316
+ }
317
+ /** Dead-letter pending transitions for an entity whose order:create failed. */
318
+ async function failDependentSyncJobs(db, businessId, entityId, message) {
319
+ try {
320
+ const all = await db.getAllAsync(`SELECT * FROM sync_jobs WHERE business_id = ?;`, businessId);
321
+ const rows = all.filter((r) => r.entity_id === entityId && r.status === "pending" && r.command_type !== "order:create");
322
+ const now = new Date().toISOString();
323
+ for (const r of rows) {
324
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `parent_create_failed:${message}`, now, r.id);
325
+ reportSyncFailure({
326
+ phase: "sync_jobs",
327
+ business_id: businessId,
328
+ command_type: r.command_type,
329
+ command_id: r.id,
330
+ entity_id: entityId,
331
+ code: "PARENT_CREATE_FAILED",
332
+ message,
333
+ attempts: r.attempts,
334
+ terminal: true,
335
+ });
336
+ }
337
+ if (rows.length) {
338
+ syncLogger.warn("SyncJobs", "Dead-lettered transitions behind failed create", { businessId, entityId, count: rows.length });
339
+ }
340
+ }
341
+ catch { }
342
+ }
297
343
  export async function drainOne(businessId, entityId) {
298
344
  if (!businessId || !canBusinessSync(businessId)) {
299
345
  syncLogger.debug("SyncJobs", "drainOne early-return — no business/auth", { businessId, entityId });
@@ -340,6 +386,32 @@ export async function drainOne(businessId, entityId) {
340
386
  return;
341
387
  }
342
388
  const payload = JSON.parse(row.payload);
389
+ // Create-before-transition: a transition whose parent order:create has
390
+ // dead-lettered can never apply — fail it now rather than POSTing to a
391
+ // guaranteed 404. A still-pending create (defensive: FIFO normally drains
392
+ // it first) defers the transition.
393
+ if (isOrderTransition(row.command_type) && row.entity_id) {
394
+ const createState = await syncJobCreateState(db, businessId, row.entity_id);
395
+ if (createState === "failed") {
396
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, "parent_create_failed", new Date().toISOString(), row.id);
397
+ try {
398
+ await markOrderSyncFailed(db, businessId, row.entity_id, "Parent order creation failed");
399
+ }
400
+ catch { }
401
+ reportJobFailure(businessId, row, { code: "PARENT_CREATE_FAILED", message: "Parent order creation failed", attempts: row.attempts, terminal: true });
402
+ try {
403
+ const { notifyError } = await import("../adapters/notify");
404
+ notifyError(`Order creation failed — discarded a follow-up ${row.command_type}`);
405
+ }
406
+ catch { }
407
+ return;
408
+ }
409
+ if (createState === "pending") {
410
+ syncLogger.debug("SyncJobs", "Transition waiting on pending create", { businessId, entityId });
411
+ scheduleRetry(businessId);
412
+ return;
413
+ }
414
+ }
343
415
  // 1) PHX first (only when cloud reachable)
344
416
  let phxOk = false;
345
417
  if (isOnlineForPhx()) {
@@ -478,6 +550,28 @@ export async function drainOne(businessId, entityId) {
478
550
  }
479
551
  catch { }
480
552
  }
553
+ // Transition 404: the server has no such order. Absorb above checked the
554
+ // already-applied race; if it did not apply, replaying can never create the
555
+ // order — dead-letter immediately instead of burning the 4xx retry budget.
556
+ if (rest.status === 404 && isOrderTransition(row.command_type)) {
557
+ syncLogger.warn("SyncJobs", "Transition 404 — order missing, failing job", { businessId, entityId, command_type: row.command_type });
558
+ try {
559
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `404 ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
560
+ }
561
+ catch { }
562
+ try {
563
+ await markOrderSyncFailed(db, businessId, row.entity_id, `Order not found on server (${rest.message ?? rest.code ?? "404"})`);
564
+ }
565
+ catch { }
566
+ reportJobFailure(businessId, row, { status: 404, code: rest.code ?? "NOT_FOUND", message: rest.message, attempts: (row.attempts ?? 0) + 1, terminal: true });
567
+ clearRetry(businessId);
568
+ try {
569
+ const { notifyError } = await import("../adapters/notify");
570
+ notifyError(`Order no longer exists on server — dropped ${row.command_type}`);
571
+ }
572
+ catch { }
573
+ return;
574
+ }
481
575
  if (isBusinessClosed) {
482
576
  syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
483
577
  reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts: row.attempts, terminal: false });
@@ -505,6 +599,14 @@ export async function drainOne(businessId, entityId) {
505
599
  catch { }
506
600
  reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: true });
507
601
  clearRetry(businessId);
602
+ // A dead-lettered order:create strands every transition behind it.
603
+ if (isOrderCreate(row.command_type)) {
604
+ try {
605
+ await markOrderSyncFailed(db, businessId, row.entity_id, rest.message ?? "Order creation failed");
606
+ }
607
+ catch { }
608
+ await failDependentSyncJobs(db, businessId, row.entity_id, rest.message ?? "Order creation failed");
609
+ }
508
610
  try {
509
611
  const { notifyError } = await import("../adapters/notify");
510
612
  notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
@@ -11,6 +11,16 @@
11
11
  */
12
12
  /** Command types fulfilled by the delivery endpoints (strictly online). */
13
13
  export declare const DELIVERY_COMMANDS: Set<string>;
14
+ /**
15
+ * Order-scoped transitions. Every one of these operates on an order that must
16
+ * already exist server-side (the `order:create` for the same entity_id must
17
+ * have landed). Used to enforce create-before-transition ordering and to make
18
+ * a 404 on a transition terminal instead of a blind retry: a transition whose
19
+ * order is gone can never succeed by being replayed.
20
+ */
21
+ export declare const ORDER_TRANSITION_COMMANDS: Set<string>;
22
+ export declare function isOrderCreate(commandType: string): boolean;
23
+ export declare function isOrderTransition(commandType: string): boolean;
14
24
  /**
15
25
  * Build the REST path for a given command type and order/delivery ID.
16
26
  * Returns null for unknown command types AND for delivery commands, which
@@ -26,6 +26,30 @@ export const DELIVERY_COMMANDS = new Set([
26
26
  "order:assign_rider",
27
27
  "order:confirm_handover",
28
28
  ]);
29
+ /**
30
+ * Order-scoped transitions. Every one of these operates on an order that must
31
+ * already exist server-side (the `order:create` for the same entity_id must
32
+ * have landed). Used to enforce create-before-transition ordering and to make
33
+ * a 404 on a transition terminal instead of a blind retry: a transition whose
34
+ * order is gone can never succeed by being replayed.
35
+ */
36
+ export const ORDER_TRANSITION_COMMANDS = new Set([
37
+ "order:accept",
38
+ "order:reject",
39
+ "order:start_preparing",
40
+ "order:mark_ready",
41
+ "order:complete_pickup",
42
+ "order:cancel",
43
+ "order:confirm_cash",
44
+ "order:pay",
45
+ "payment:confirm_cash",
46
+ ]);
47
+ export function isOrderCreate(commandType) {
48
+ return commandType === "order:create";
49
+ }
50
+ export function isOrderTransition(commandType) {
51
+ return ORDER_TRANSITION_COMMANDS.has(commandType);
52
+ }
29
53
  /**
30
54
  * Build the REST path for a given command type and order/delivery ID.
31
55
  * Returns null for unknown command types AND for delivery commands, which
@@ -277,6 +277,15 @@ export function createMemoryDriver() {
277
277
  }
278
278
  return { lastInsertRowId: 0, changes: 1 };
279
279
  }
280
+ if (s.includes("UPDATE orders SET sync_failed_at = ?")) {
281
+ const [sync_failed_at, sync_error, business_id, id1, id2] = args;
282
+ const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
283
+ if (row) {
284
+ row.sync_failed_at = sync_failed_at;
285
+ row.sync_error = sync_error;
286
+ }
287
+ return { lastInsertRowId: 0, changes: 1 };
288
+ }
280
289
  // cleanup deletes for v7
281
290
  if (s.includes("DELETE FROM orders WHERE order_number = '[object Object]'")) {
282
291
  const before = tables.orders.length;
@@ -401,24 +410,61 @@ export function createMemoryDriver() {
401
410
  rows = rows.slice(0, limit);
402
411
  return rows;
403
412
  }
404
- // orders list with filters (simple business_id filter + pagination)
413
+ // orders list with filters (business_id + status/order_type/is_paid/q/date)
405
414
  if (s.includes("SELECT * FROM orders WHERE")) {
406
415
  const business_id = args[0];
407
- // args includes where-clause params plus LIMIT and OFFSET at end
408
- // For test, just filter by business_id and apply basic status/q if present in sql string via args slicing?
409
- // Simpler: return all orders for that business, sorted by inserted_at desc, handle LIMIT/OFFSET manually
410
416
  let rows = tables.orders.filter((r) => r.business_id === business_id);
411
- // handle status filter if sql contains status IN
417
+ // Consume filter args in the exact clause order produced by listOrders.
418
+ let i = 1;
412
419
  if (s.includes("status IN")) {
413
- // find args that are status values (excluding business_id, limit, offset)
414
- // Our sql: SELECT * FROM orders WHERE business_id = ? AND status IN (?,?) ... LIMIT ? OFFSET ?
415
- // So statuses are args[1]..args[?]
416
- // Count statuses by parsing SQL placeholder count?
417
- // For test, just extract known statuses from args that look like status strings
418
- const possibleStatuses = ["pending", "awaiting_payment", "accepted", "preparing", "ready", "waiting_pickup", "on_the_way", "arrived", "delivered", "cancelled", "assigned", "picked_up", "en_route", "failed"];
419
- const statusArgs = args.filter((v) => possibleStatuses.includes(String(v)));
420
- if (statusArgs.length)
421
- rows = rows.filter((r) => statusArgs.includes(r.status));
420
+ const n = (s.match(/status IN \([^)]*\)/)[0].match(/\?/g) || []).length;
421
+ const statuses = args.slice(i, i + n);
422
+ i += n;
423
+ if (statuses.length)
424
+ rows = rows.filter((r) => statuses.includes(r.status));
425
+ }
426
+ if (s.includes("is_pos = ?")) {
427
+ const v = Number(args[i++]) === 1;
428
+ rows = rows.filter((r) => !!r.is_pos === v);
429
+ }
430
+ if (s.includes("order_type = ?")) {
431
+ const t = args[i++];
432
+ rows = rows.filter((r) => r.order_type === t);
433
+ }
434
+ if (s.includes("json_extract(payment, '$.is_paid') = 1")) {
435
+ rows = rows.filter((r) => {
436
+ try {
437
+ return JSON.parse(r.payment ?? "{}").is_paid === true;
438
+ }
439
+ catch {
440
+ return false;
441
+ }
442
+ });
443
+ }
444
+ else if (s.includes("json_extract(payment, '$.is_paid') IS NULL")) {
445
+ rows = rows.filter((r) => {
446
+ try {
447
+ return JSON.parse(r.payment ?? "{}").is_paid !== true;
448
+ }
449
+ catch {
450
+ return true;
451
+ }
452
+ });
453
+ }
454
+ if (s.includes("order_number LIKE ?")) {
455
+ const term = String(args[i]).replace(/%/g, "").toLowerCase();
456
+ i += 3;
457
+ rows = rows.filter((r) => String(r.order_number ?? "").toLowerCase().includes(term) ||
458
+ String(r.customer_name ?? "").toLowerCase().includes(term) ||
459
+ String(r.customer_phone ?? "").toLowerCase().includes(term));
460
+ }
461
+ if (s.includes("inserted_at >= ?")) {
462
+ const from = String(args[i++]);
463
+ rows = rows.filter((r) => String(r.inserted_at) >= from);
464
+ }
465
+ if (s.includes("inserted_at < ?")) {
466
+ const to = String(args[i++]);
467
+ rows = rows.filter((r) => String(r.inserted_at) < to);
422
468
  }
423
469
  rows = rows.sort((a, b) => (a.inserted_at < b.inserted_at ? 1 : -1));
424
470
  const limit = args[args.length - 2];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Date-range presets for order/delivery filters.
3
+ *
4
+ * Returns YYYY-MM-DD strings (local day boundaries) that both the backend
5
+ * (normalizes to UTC day bounds) and the local SQLite read-model
6
+ * (`inserted_at >= from` / `inserted_at < `${to}T23:59:59``) accept.
7
+ */
8
+ export type DatePreset = "all" | "today" | "yesterday" | "last7" | "custom";
9
+ export interface DateRange {
10
+ date_from?: string;
11
+ date_to?: string;
12
+ }
13
+ export declare function resolveDateRange(preset: DatePreset | string | undefined, customFrom?: string, customTo?: string): DateRange;
14
+ export declare const DATE_PRESET_LABELS: Array<{
15
+ key: DatePreset;
16
+ label: string;
17
+ }>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Date-range presets for order/delivery filters.
3
+ *
4
+ * Returns YYYY-MM-DD strings (local day boundaries) that both the backend
5
+ * (normalizes to UTC day bounds) and the local SQLite read-model
6
+ * (`inserted_at >= from` / `inserted_at < `${to}T23:59:59``) accept.
7
+ */
8
+ function toISODate(d) {
9
+ const y = d.getFullYear();
10
+ const m = String(d.getMonth() + 1).padStart(2, "0");
11
+ const day = String(d.getDate()).padStart(2, "0");
12
+ return `${y}-${m}-${day}`;
13
+ }
14
+ function addDays(base, days) {
15
+ const d = new Date(base);
16
+ d.setDate(d.getDate() + days);
17
+ return d;
18
+ }
19
+ export function resolveDateRange(preset, customFrom, customTo) {
20
+ const today = new Date();
21
+ switch (preset) {
22
+ case "today":
23
+ return { date_from: toISODate(today), date_to: toISODate(today) };
24
+ case "yesterday": {
25
+ const y = toISODate(addDays(today, -1));
26
+ return { date_from: y, date_to: y };
27
+ }
28
+ case "last7":
29
+ return { date_from: toISODate(addDays(today, -6)), date_to: toISODate(today) };
30
+ case "custom":
31
+ return {
32
+ ...(customFrom ? { date_from: customFrom } : {}),
33
+ ...(customTo ? { date_to: customTo } : {}),
34
+ };
35
+ default:
36
+ return {};
37
+ }
38
+ }
39
+ export const DATE_PRESET_LABELS = [
40
+ { key: "all", label: "All time" },
41
+ { key: "today", label: "Today" },
42
+ { key: "yesterday", label: "Yesterday" },
43
+ { key: "last7", label: "Last 7 days" },
44
+ { key: "custom", label: "Custom" },
45
+ ];
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Format the time-of-day of an ISO date string (e.g. "2:30 PM").
3
+ */
4
+ export declare function formatTime(isoString: string): string;
5
+ /**
6
+ * Compact date (e.g. "26 Sep 2026") for list cards where the year may be
7
+ * implied but is still useful for older orders.
8
+ */
9
+ export declare function formatDateCompact(isoString: string): string;
10
+ /**
11
+ * Date + time for order/delivery cards: "26 Sep 2026, 2:30 PM".
12
+ */
13
+ export declare function formatDateTime(isoString: string): string;
1
14
  /**
2
15
  * Format an ISO date string into a human-readable format.
3
16
  */
@@ -1,3 +1,32 @@
1
+ /**
2
+ * Format the time-of-day of an ISO date string (e.g. "2:30 PM").
3
+ */
4
+ export function formatTime(isoString) {
5
+ const date = new Date(isoString);
6
+ return date.toLocaleTimeString('en-US', {
7
+ hour: 'numeric',
8
+ minute: '2-digit',
9
+ hour12: true,
10
+ });
11
+ }
12
+ /**
13
+ * Compact date (e.g. "26 Sep 2026") for list cards where the year may be
14
+ * implied but is still useful for older orders.
15
+ */
16
+ export function formatDateCompact(isoString) {
17
+ const date = new Date(isoString);
18
+ return date.toLocaleDateString('en-US', {
19
+ day: '2-digit',
20
+ month: 'short',
21
+ year: 'numeric',
22
+ });
23
+ }
24
+ /**
25
+ * Date + time for order/delivery cards: "26 Sep 2026, 2:30 PM".
26
+ */
27
+ export function formatDateTime(isoString) {
28
+ return `${formatDateCompact(isoString)}, ${formatTime(isoString)}`;
29
+ }
1
30
  /**
2
31
  * Format an ISO date string into a human-readable format.
3
32
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
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",