@teincfood/core 0.7.8 → 0.8.0

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.
Files changed (198) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +10 -0
  3. package/dist/adapters/auth.js +43 -0
  4. package/dist/adapters/devices.d.ts +3 -1
  5. package/dist/adapters/devices.js +187 -3
  6. package/dist/adapters/filesystem.d.ts +4 -0
  7. package/dist/adapters/kv.d.ts +10 -0
  8. package/dist/adapters/kv.js +23 -7
  9. package/dist/adapters/local-node.d.ts +4 -6
  10. package/dist/adapters/local-node.js +9 -32
  11. package/dist/adapters/notify.d.ts +12 -0
  12. package/dist/adapters/notify.js +33 -0
  13. package/dist/adapters/query-keys.d.ts +4 -0
  14. package/dist/adapters/query-keys.js +4 -0
  15. package/dist/adapters/services.d.ts +99 -15
  16. package/dist/adapters/services.js +244 -4
  17. package/dist/auth/session.d.ts +66 -0
  18. package/dist/auth/session.js +172 -0
  19. package/dist/catalog/hooks.d.ts +10 -0
  20. package/dist/catalog/hooks.js +82 -11
  21. package/dist/db/migrations.js +274 -0
  22. package/dist/db/operations.d.ts +53 -2
  23. package/dist/db/operations.js +177 -8
  24. package/dist/domain/business/index.d.ts +5 -0
  25. package/dist/domain/business/index.js +5 -0
  26. package/dist/domain/orders/lifecycle.d.ts +6 -0
  27. package/dist/domain/orders/lifecycle.js +6 -0
  28. package/dist/domain/orders/service.d.ts +24 -0
  29. package/dist/domain/orders/service.js +242 -0
  30. package/dist/domain/payments/index.d.ts +6 -0
  31. package/dist/domain/payments/index.js +5 -0
  32. package/dist/domain/reference/index.d.ts +8 -0
  33. package/dist/domain/reference/index.js +8 -0
  34. package/dist/e2e/harness.d.ts +1 -0
  35. package/dist/e2e/harness.js +61 -0
  36. package/dist/hooks/analytics.hooks.d.ts +25 -0
  37. package/dist/hooks/analytics.hooks.js +214 -0
  38. package/dist/hooks/business-hours.hooks.d.ts +8 -0
  39. package/dist/hooks/business-hours.hooks.js +45 -0
  40. package/dist/hooks/dashboard.hooks.d.ts +30 -0
  41. package/dist/hooks/dashboard.hooks.js +132 -0
  42. package/dist/hooks/delivery.hooks.d.ts +225 -0
  43. package/dist/hooks/delivery.hooks.js +80 -0
  44. package/dist/hooks/fleet.hooks.d.ts +37 -0
  45. package/dist/hooks/fleet.hooks.js +229 -0
  46. package/dist/hooks/image.hooks.d.ts +20 -0
  47. package/dist/hooks/image.hooks.js +62 -0
  48. package/dist/hooks/kiosk.hooks.d.ts +22 -5
  49. package/dist/hooks/kiosk.hooks.js +74 -5
  50. package/dist/hooks/members.hooks.d.ts +48 -0
  51. package/dist/hooks/members.hooks.js +302 -0
  52. package/dist/hooks/offline-orders.hooks.d.ts +9 -0
  53. package/dist/hooks/offline-orders.hooks.js +51 -0
  54. package/dist/hooks/orders.hooks.d.ts +19 -39
  55. package/dist/hooks/orders.hooks.js +126 -321
  56. package/dist/hooks/places.hooks.d.ts +21 -0
  57. package/dist/hooks/places.hooks.js +112 -0
  58. package/dist/hooks/realtime-stubs.d.ts +28 -0
  59. package/dist/hooks/realtime-stubs.js +85 -0
  60. package/dist/hooks/rider.hooks.d.ts +470 -0
  61. package/dist/hooks/rider.hooks.js +373 -0
  62. package/dist/hooks/use-infinite-list.d.ts +243 -0
  63. package/dist/hooks/use-infinite-list.js +58 -0
  64. package/dist/hooks/wallet.hooks.d.ts +219 -0
  65. package/dist/hooks/wallet.hooks.js +56 -0
  66. package/dist/image-cache/service.d.ts +8 -0
  67. package/dist/image-cache/service.js +15 -3
  68. package/dist/index.d.ts +60 -5
  69. package/dist/index.js +78 -5
  70. package/dist/local-node/adapters.d.ts +87 -0
  71. package/dist/local-node/adapters.js +4 -0
  72. package/dist/local-node/index.d.ts +5 -0
  73. package/dist/local-node/index.js +5 -0
  74. package/dist/local-node/security.d.ts +25 -0
  75. package/dist/local-node/security.js +149 -0
  76. package/dist/local-node/service.d.ts +83 -0
  77. package/dist/local-node/service.js +706 -0
  78. package/dist/local-node/store.d.ts +50 -0
  79. package/dist/local-node/store.js +284 -0
  80. package/dist/local-node/types.d.ts +92 -0
  81. package/dist/local-node/types.js +4 -0
  82. package/dist/printer/receipt.d.ts +4 -0
  83. package/dist/printer/receipt.js +75 -0
  84. package/dist/printer/service.js +1 -1
  85. package/dist/providers/query-client.d.ts +13 -0
  86. package/dist/providers/query-client.js +58 -0
  87. package/dist/realtime/events.service.d.ts +18 -0
  88. package/dist/realtime/events.service.js +76 -0
  89. package/dist/realtime/order-events.service.d.ts +6 -0
  90. package/dist/realtime/order-events.service.js +17 -0
  91. package/dist/realtime/phoenix-socket.d.ts +55 -0
  92. package/dist/realtime/phoenix-socket.js +269 -0
  93. package/dist/realtime/useEventsConnection.d.ts +1 -0
  94. package/dist/realtime/useEventsConnection.js +50 -0
  95. package/dist/reference/api.d.ts +2 -0
  96. package/dist/reference/api.js +2 -0
  97. package/dist/reference/capabilities.d.ts +12 -7
  98. package/dist/reference/capabilities.js +28 -13
  99. package/dist/reference/hooks.d.ts +34 -0
  100. package/dist/reference/hooks.js +124 -0
  101. package/dist/reference/menu-apply.d.ts +29 -0
  102. package/dist/reference/menu-apply.js +289 -0
  103. package/dist/reference/menu-record.d.ts +69 -0
  104. package/dist/reference/menu-record.js +163 -0
  105. package/dist/reference/operations.d.ts +3 -0
  106. package/dist/reference/operations.js +35 -1
  107. package/dist/reference/service.js +36 -9
  108. package/dist/reference/types.d.ts +1 -1
  109. package/dist/repositories/order.repository.d.ts +8 -0
  110. package/dist/repositories/order.repository.js +24 -2
  111. package/dist/repositories/pos.repository.d.ts +4 -0
  112. package/dist/repositories/pos.repository.js +4 -0
  113. package/dist/sync/backoff.d.ts +21 -0
  114. package/dist/sync/backoff.js +42 -0
  115. package/dist/sync/command-builder.js +10 -2
  116. package/dist/sync/command-queue.service.d.ts +12 -0
  117. package/dist/sync/command-queue.service.js +115 -18
  118. package/dist/sync/device-lease.service.d.ts +3 -3
  119. package/dist/sync/device-lease.service.js +80 -20
  120. package/dist/sync/engine.d.ts +3 -1
  121. package/dist/sync/engine.js +44 -4
  122. package/dist/sync/full-sync.d.ts +6 -0
  123. package/dist/sync/full-sync.js +72 -0
  124. package/dist/sync/health-probe.d.ts +24 -0
  125. package/dist/sync/health-probe.js +138 -0
  126. package/dist/sync/offline-capable.js +25 -0
  127. package/dist/sync/online-gates.d.ts +17 -0
  128. package/dist/sync/online-gates.js +55 -0
  129. package/dist/sync/order-number.d.ts +4 -4
  130. package/dist/sync/order-number.js +33 -13
  131. package/dist/sync/order-snapshots.service.d.ts +1 -0
  132. package/dist/sync/order-snapshots.service.js +47 -2
  133. package/dist/sync/pending-orders.service.d.ts +18 -0
  134. package/dist/sync/pending-orders.service.js +148 -3
  135. package/dist/sync/reconciliation/index.d.ts +5 -0
  136. package/dist/sync/reconciliation/index.js +5 -0
  137. package/dist/sync/snapshots/entity.d.ts +6 -0
  138. package/dist/sync/snapshots/entity.js +6 -0
  139. package/dist/sync/sync-jobs.service.d.ts +18 -0
  140. package/dist/sync/sync-jobs.service.js +539 -0
  141. package/dist/sync/transition-endpoints.d.ts +11 -0
  142. package/dist/sync/transition-endpoints.js +30 -0
  143. package/dist/sync/transport-local-node.js +3 -2
  144. package/dist/sync/transport-rest.d.ts +7 -0
  145. package/dist/sync/transport-rest.js +90 -25
  146. package/dist/sync/use-sync-reconciliation.js +58 -16
  147. package/dist/test-helpers/memory-driver.js +182 -4
  148. package/dist/test-helpers/seed-hours.d.ts +1 -0
  149. package/dist/test-helpers/seed-hours.js +23 -0
  150. package/dist/types/api/business.api.d.ts +42 -0
  151. package/dist/types/api/menu.api.d.ts +15 -0
  152. package/dist/types/api/seller.api.d.ts +20 -0
  153. package/dist/types/places.d.ts +19 -0
  154. package/dist/types/places.js +2 -0
  155. package/dist/types/rider.d.ts +67 -0
  156. package/dist/types/rider.js +2 -0
  157. package/dist/utils/attempt-stats.d.ts +23 -0
  158. package/dist/utils/attempt-stats.js +61 -0
  159. package/dist/utils/business-hours.d.ts +37 -0
  160. package/dist/utils/business-hours.js +96 -0
  161. package/dist/utils/debug-log.d.ts +23 -0
  162. package/dist/utils/debug-log.js +153 -0
  163. package/dist/utils/order-filter.d.ts +1 -1
  164. package/dist/utils/order-filter.js +1 -1
  165. package/dist/utils/order-transitions.d.ts +30 -0
  166. package/dist/utils/order-transitions.js +185 -0
  167. package/dist/utils/sync-logger.js +5 -4
  168. package/dist/validation/offline.d.ts +46 -0
  169. package/dist/validation/offline.js +79 -0
  170. package/package.json +6 -1
  171. package/dist/pricing/engine.test.d.ts +0 -1
  172. package/dist/pricing/engine.test.js +0 -142
  173. package/dist/reference/lww.test.d.ts +0 -1
  174. package/dist/reference/lww.test.js +0 -23
  175. package/dist/reference/operations.test.d.ts +0 -1
  176. package/dist/reference/operations.test.js +0 -66
  177. package/dist/standalone.integration.test.d.ts +0 -1
  178. package/dist/standalone.integration.test.js +0 -235
  179. package/dist/sync/device-lease.test.d.ts +0 -1
  180. package/dist/sync/device-lease.test.js +0 -38
  181. package/dist/sync/offline-integration.test.d.ts +0 -1
  182. package/dist/sync/offline-integration.test.js +0 -52
  183. package/dist/sync/order-number.test.d.ts +0 -1
  184. package/dist/sync/order-number.test.js +0 -51
  185. package/dist/sync/perms.test.d.ts +0 -1
  186. package/dist/sync/perms.test.js +0 -15
  187. package/dist/utils/currency.test.d.ts +0 -1
  188. package/dist/utils/currency.test.js +0 -17
  189. package/dist/utils/hlc.test.d.ts +0 -1
  190. package/dist/utils/hlc.test.js +0 -29
  191. package/dist/utils/phone.test.d.ts +0 -1
  192. package/dist/utils/phone.test.js +0 -19
  193. package/dist/utils/responsive.d.ts +0 -13
  194. package/dist/utils/responsive.js +0 -18
  195. package/dist/utils/uuid.test.d.ts +0 -1
  196. package/dist/utils/uuid.test.js +0 -13
  197. package/dist/utils/validation.test.d.ts +0 -1
  198. package/dist/utils/validation.test.js +0 -23
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { SQLiteDatabase } from "../adapters/sqlite";
5
5
  import type { ReferenceDataKey, ReferenceDataRecord } from "./types";
6
+ import type { OperatingHoursEntry } from "../types/api/business.api";
6
7
  export declare function getReferenceData<T>(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey): Promise<ReferenceDataRecord<T> | null>;
7
8
  export declare function getReferenceVersion(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey): Promise<string | null>;
8
9
  export declare function setReferenceData<T>(db: SQLiteDatabase, businessId: string, record: ReferenceDataRecord<T>): Promise<void>;
@@ -10,3 +11,5 @@ export declare function mergeReferenceData<T extends Record<string, unknown>>(db
10
11
  export declare function deleteReferenceData(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey): Promise<void>;
11
12
  export declare function deleteReferenceDataForBusiness(db: SQLiteDatabase, businessId: string): Promise<void>;
12
13
  export declare function getAllReferenceDataKeys(db: SQLiteDatabase, businessId: string): Promise<ReferenceDataKey[]>;
14
+ export declare function setOperatingHoursLocal(businessId: string, entries: OperatingHoursEntry[]): Promise<void>;
15
+ export declare function setBusinessProfileLocal(businessId: string, profile: Record<string, unknown>): Promise<void>;
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * SQLite operations for the business-scoped reference data cache.
3
3
  */
4
+ import { compareHLCString, hlcToString, tickHLC, hlcFromString } from "../utils/hlc";
5
+ import { generateUUID } from "../utils/uuid";
6
+ import { openSyncDatabase } from "../db/connection";
7
+ import { getAppInstanceId } from "../adapters/devices";
8
+ import { notifyDbChange } from "../db/operations";
4
9
  export async function getReferenceData(db, businessId, key) {
5
10
  const row = await db.getFirstAsync("SELECT * FROM reference_data WHERE business_id = ? AND key = ?;", businessId, key);
6
11
  if (!row)
@@ -29,9 +34,9 @@ export async function setReferenceData(db, businessId, record) {
29
34
  last_event_id = excluded.last_event_id,
30
35
  last_hlc = excluded.last_hlc;
31
36
  `, businessId, record.key, record.version, JSON.stringify(record.payload), record.downloadedAt, record.lastEventId ?? null, record.lastHlc ?? null);
37
+ notifyDbChange(businessId, `reference_data:${record.key}`);
32
38
  }
33
39
  export async function mergeReferenceData(db, businessId, key, incoming, hlc, eventId) {
34
- const { compareHLCString } = await import("../utils/hlc");
35
40
  const existing = await getReferenceData(db, businessId, key);
36
41
  if (!existing) {
37
42
  await setReferenceData(db, businessId, {
@@ -68,3 +73,32 @@ export async function getAllReferenceDataKeys(db, businessId) {
68
73
  const rows = await db.getAllAsync("SELECT key FROM reference_data WHERE business_id = ? ORDER BY key;", businessId);
69
74
  return rows.map((r) => r.key);
70
75
  }
76
+ async function enqueueReferenceSyncJob(businessId, key, hlc) {
77
+ try {
78
+ const db = await openSyncDatabase();
79
+ const id = generateUUID();
80
+ const now = new Date().toISOString();
81
+ await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at, attempts, last_error) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, NULL);`, id, businessId, "reference:sync", key, JSON.stringify({ key, hlc }), now, now);
82
+ }
83
+ catch { }
84
+ }
85
+ export async function setOperatingHoursLocal(businessId, entries) {
86
+ const db = await openSyncDatabase();
87
+ const payload = { data: entries };
88
+ const now = new Date().toISOString();
89
+ // HLC ensures local edit wins over stale server pull via LWW
90
+ const existing = await getReferenceData(db, businessId, "operating-hours");
91
+ const lastHlc = existing?.lastHlc ? hlcFromString(existing.lastHlc) : null;
92
+ const hlcStr = hlcToString(tickHLC(Date.now(), lastHlc, getAppInstanceId()));
93
+ await mergeReferenceData(db, businessId, "operating-hours", payload, hlcStr, `evt_${hlcStr}`);
94
+ await enqueueReferenceSyncJob(businessId, "operating-hours", hlcStr);
95
+ }
96
+ export async function setBusinessProfileLocal(businessId, profile) {
97
+ const db = await openSyncDatabase();
98
+ const payload = { data: profile };
99
+ const existing = await getReferenceData(db, businessId, "business:profile");
100
+ const lastHlc = existing?.lastHlc ? hlcFromString(existing.lastHlc) : null;
101
+ const hlcStr = hlcToString(tickHLC(Date.now(), lastHlc, getAppInstanceId()));
102
+ await mergeReferenceData(db, businessId, "business:profile", payload, hlcStr, `evt_${hlcStr}`);
103
+ await enqueueReferenceSyncJob(businessId, "business:profile", hlcStr);
104
+ }
@@ -12,7 +12,8 @@ import { syncLogger } from "../utils/sync-logger";
12
12
  import { generateUUID } from "../utils/uuid";
13
13
  import { menuService, businessService, rolesService } from "../adapters/services";
14
14
  import { cacheImageBatch, pruneImageCache } from "../adapters/image-cache";
15
- import { getReferenceData, getReferenceVersion, setReferenceData, } from "./operations";
15
+ import { getConnectivityState } from "../adapters/connectivity";
16
+ import { getReferenceData, getReferenceVersion, mergeReferenceData, } from "./operations";
16
17
  import { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, } from "./api";
17
18
  const DEFAULT_SYNC_KEYS = [
18
19
  "menu:items",
@@ -22,6 +23,8 @@ const DEFAULT_SYNC_KEYS = [
22
23
  "tax-rules",
23
24
  "business:profile",
24
25
  "operating-hours",
26
+ "delivery-settings",
27
+ "payment-settings",
25
28
  "subscription:entitlements",
26
29
  "capabilities",
27
30
  ];
@@ -38,6 +41,20 @@ class ReferenceSyncService {
38
41
  errors: {},
39
42
  };
40
43
  }
44
+ const { isOnline, isCloudReachable } = getConnectivityState();
45
+ if (!isOnline || isCloudReachable === "unreachable") {
46
+ syncLogger.debug("ReferenceSync", "Skipping sync — cloud not reachable", {
47
+ businessId: options.businessId,
48
+ isOnline,
49
+ isCloudReachable,
50
+ });
51
+ return {
52
+ synced: [],
53
+ skipped: options.keys ?? DEFAULT_SYNC_KEYS,
54
+ failed: [],
55
+ errors: {},
56
+ };
57
+ }
41
58
  this.isSyncing = true;
42
59
  syncLogger.info("ReferenceSync", "Starting reference data sync", {
43
60
  businessId: options.businessId,
@@ -143,14 +160,9 @@ class ReferenceSyncService {
143
160
  payload = fallback.payload;
144
161
  version = fallback.version;
145
162
  }
146
- await setReferenceData(db, businessId, {
147
- key,
148
- version: version ?? generateUUID(),
149
- payload,
150
- downloadedAt: new Date().toISOString(),
151
- lastHlc: version ?? generateUUID(),
152
- lastEventId: `evt_${version ?? generateUUID()}`,
153
- });
163
+ // LWW: newer HLC wins — local edits that are newer than server are preserved.
164
+ const hlc = version ?? generateUUID();
165
+ await mergeReferenceData(db, businessId, key, payload, hlc, `evt_${hlc}`);
154
166
  return true;
155
167
  }
156
168
  async fetchFallback(key, businessId) {
@@ -244,6 +256,21 @@ class ReferenceSyncService {
244
256
  version: this.hashPayload(response),
245
257
  };
246
258
  }
259
+ case "delivery-settings": {
260
+ const response = await businessService.fetchDeliverySettings?.(businessId) ?? (await (await import("../adapters/http")).getHttpClient().get(`/businesses/${businessId}/delivery-settings`).then((r) => r.data));
261
+ return {
262
+ payload: response,
263
+ version: this.hashPayload(response),
264
+ };
265
+ }
266
+ case "payment-settings": {
267
+ const response = await businessService.fetchBusinessById(businessId);
268
+ const payload = response?.data?.paymentSettings ?? response;
269
+ return {
270
+ payload,
271
+ version: this.hashPayload(payload),
272
+ };
273
+ }
247
274
  default:
248
275
  throw new Error(`No fallback fetch available for ${key}`);
249
276
  }
@@ -8,7 +8,7 @@
8
8
  * It is synchronized separately from transactional data through the
9
9
  * ReferenceSyncService.
10
10
  */
11
- export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "subscription:entitlements" | "capabilities";
11
+ export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "delivery-settings" | "payment-settings" | "subscription:entitlements" | "capabilities";
12
12
  export interface ReferenceDataRecord<T = unknown> {
13
13
  key: ReferenceDataKey;
14
14
  version: string;
@@ -24,6 +24,13 @@ export interface OrderActionInput {
24
24
  businessId: string;
25
25
  }
26
26
  export declare const orderRepository: {
27
+ /**
28
+ * Single create path: build an `order:create` command and route it through
29
+ * the SyncEngine (cloud → LAN node → queue). Every path returns an
30
+ * order-shaped payload: cloud/LAN execute the real create, queue returns
31
+ * the optimistic order (same UUID + DD-RRR-CCC, replayed idempotently).
32
+ * Direct `createLocalOrder` bypasses sync — never call it from UI flows.
33
+ */
27
34
  createOrder(input: CreateOrderInput): Promise<SellerOrder>;
28
35
  acceptOrder(input: OrderActionInput): Promise<SellerOrder>;
29
36
  rejectOrder(input: OrderActionInput): Promise<SellerOrder>;
@@ -41,4 +48,5 @@ export declare const orderRepository: {
41
48
  assignRider(input: AssignRiderInput & {
42
49
  businessId: string;
43
50
  }): Promise<unknown>;
51
+ confirmCashPayment(input: OrderActionInput): Promise<SellerOrder>;
44
52
  };
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { syncEngine } from "../sync/engine";
11
11
  import { buildCommand } from "../sync/command-builder";
12
+ import { generateUUID } from "../utils/uuid";
12
13
  async function executeCommand(command) {
13
14
  console.debug("[OrderRepository] Dispatching command", JSON.stringify({
14
15
  command_id: command.command_id,
@@ -35,12 +36,24 @@ async function executeCommand(command) {
35
36
  return event.payload;
36
37
  }
37
38
  export const orderRepository = {
39
+ /**
40
+ * Single create path: build an `order:create` command and route it through
41
+ * the SyncEngine (cloud → LAN node → queue). Every path returns an
42
+ * order-shaped payload: cloud/LAN execute the real create, queue returns
43
+ * the optimistic order (same UUID + DD-RRR-CCC, replayed idempotently).
44
+ * Direct `createLocalOrder` bypasses sync — never call it from UI flows.
45
+ */
38
46
  async createOrder(input) {
47
+ const entityId = input.entityId ?? generateUUID();
39
48
  const command = buildCommand({
40
49
  commandType: "order:create",
41
50
  businessId: input.businessId,
42
- entityId: input.entityId,
43
- payload: input.data,
51
+ entityId,
52
+ payload: {
53
+ ...input.data,
54
+ id: entityId,
55
+ command_id: entityId,
56
+ },
44
57
  });
45
58
  return executeCommand(command);
46
59
  },
@@ -126,4 +139,13 @@ export const orderRepository = {
126
139
  }
127
140
  return event.payload;
128
141
  },
142
+ async confirmCashPayment(input) {
143
+ const command = buildCommand({
144
+ commandType: "payment:confirm_cash",
145
+ businessId: input.businessId,
146
+ entityId: input.orderId,
147
+ payload: {},
148
+ });
149
+ return executeCommand(command);
150
+ },
129
151
  };
@@ -14,6 +14,10 @@ export interface POSOrderPreviewInput {
14
14
  customerName?: string;
15
15
  }
16
16
  export declare const posRepository: {
17
+ /**
18
+ * @deprecated Single create path is `createLocalOrder` (domain/orders/service).
19
+ * Forwards via `orderRepository.createOrder`, which delegates there directly.
20
+ */
17
21
  createOrder(businessId: string, data: POSOrderCreateRequest, entityId?: string): Promise<SellerOrder>;
18
22
  fetchPreview(input: POSOrderPreviewInput): Promise<POSOrderPreviewResponse>;
19
23
  fetchOrders(businessId: string, params?: Record<string, string>): Promise<SellerOrdersResponse>;
@@ -7,6 +7,10 @@
7
7
  import { posService } from "../adapters/services";
8
8
  import { orderRepository } from "./order.repository";
9
9
  export const posRepository = {
10
+ /**
11
+ * @deprecated Single create path is `createLocalOrder` (domain/orders/service).
12
+ * Forwards via `orderRepository.createOrder`, which delegates there directly.
13
+ */
10
14
  async createOrder(businessId, data, entityId) {
11
15
  return orderRepository.createOrder({ businessId, data, entityId });
12
16
  },
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared retry backoff — one curve everywhere (sync retries, probes stay fixed).
3
+ *
4
+ * delay(attempts) = min(MAX, BASE * 2^attempts) + jitter(±20%).
5
+ * Attempts reset on any success or connectivity regain, so one bad night
6
+ * never permanently slows the device, and a fleet doesn't thunder-herd a
7
+ * recovering backend at the same millisecond.
8
+ */
9
+ export declare const BACKOFF_BASE_MS = 2000;
10
+ export declare const BACKOFF_MAX_MS: number;
11
+ export declare function backoffDelayMs(attempts: number): number;
12
+ /** Deterministic center of the curve (no jitter) — for tests and logging. */
13
+ export declare function backoffCenterMs(attempts: number): number;
14
+ /** Attempt counters keyed by scope (e.g. business id). Reset on success/regain. */
15
+ export declare class BackoffTracker {
16
+ private attempts;
17
+ next(scope: string): number;
18
+ reset(scope: string): void;
19
+ resetAll(): void;
20
+ count(scope: string): number;
21
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared retry backoff — one curve everywhere (sync retries, probes stay fixed).
3
+ *
4
+ * delay(attempts) = min(MAX, BASE * 2^attempts) + jitter(±20%).
5
+ * Attempts reset on any success or connectivity regain, so one bad night
6
+ * never permanently slows the device, and a fleet doesn't thunder-herd a
7
+ * recovering backend at the same millisecond.
8
+ */
9
+ export const BACKOFF_BASE_MS = 2000;
10
+ export const BACKOFF_MAX_MS = 5 * 60000;
11
+ const JITTER_RATIO = 0.2;
12
+ export function backoffDelayMs(attempts) {
13
+ const safe = Math.max(0, Math.floor(attempts));
14
+ const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * Math.pow(2, safe));
15
+ const jitter = capped * JITTER_RATIO * (Math.random() * 2 - 1);
16
+ return Math.max(0, Math.round(capped + jitter));
17
+ }
18
+ /** Deterministic center of the curve (no jitter) — for tests and logging. */
19
+ export function backoffCenterMs(attempts) {
20
+ const safe = Math.max(0, Math.floor(attempts));
21
+ return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * Math.pow(2, safe));
22
+ }
23
+ /** Attempt counters keyed by scope (e.g. business id). Reset on success/regain. */
24
+ export class BackoffTracker {
25
+ constructor() {
26
+ this.attempts = new Map();
27
+ }
28
+ next(scope) {
29
+ const n = this.attempts.get(scope) ?? 0;
30
+ this.attempts.set(scope, n + 1);
31
+ return backoffDelayMs(n);
32
+ }
33
+ reset(scope) {
34
+ this.attempts.delete(scope);
35
+ }
36
+ resetAll() {
37
+ this.attempts.clear();
38
+ }
39
+ count(scope) {
40
+ return this.attempts.get(scope) ?? 0;
41
+ }
42
+ }
@@ -11,6 +11,14 @@ export function buildCommand(options) {
11
11
  const user = useAuthStore.getState().user;
12
12
  const userId = user?.id ?? "anonymous";
13
13
  const deviceId = getAppInstanceId();
14
+ const timestamp = new Date().toISOString();
15
+ // Inject inserted_at / updated_at into payload so the backend preserves the
16
+ // original client creation time for offline-first sync.
17
+ const payload = {
18
+ ...options.payload,
19
+ inserted_at: timestamp,
20
+ updated_at: timestamp,
21
+ };
14
22
  return {
15
23
  command_id: generateCommandId(),
16
24
  command_type: options.commandType,
@@ -19,8 +27,8 @@ export function buildCommand(options) {
19
27
  entity_id: options.entityId,
20
28
  user_id: userId,
21
29
  device_id: deviceId,
22
- timestamp: new Date().toISOString(),
23
- payload: options.payload,
30
+ timestamp,
31
+ payload,
24
32
  };
25
33
  }
26
34
  export { getAppInstanceId };
@@ -37,6 +37,18 @@ declare class CommandQueueService {
37
37
  * Return the number of commands currently in the outbox.
38
38
  */
39
39
  getPendingCount(): Promise<number>;
40
+ /**
41
+ * Debug dump of the engine outbox for Diagnostics screens (all statuses,
42
+ * newest last). Mirrors debugDumpSyncJobs.
43
+ */
44
+ debugDumpCommands(businessId: string): Promise<Array<{
45
+ id: string;
46
+ command_type: string;
47
+ status: string;
48
+ retry_count: number;
49
+ error: string | null;
50
+ created_at: string;
51
+ }>>;
40
52
  }
41
53
  export declare const commandQueueService: CommandQueueService;
42
54
  export {};
@@ -11,8 +11,9 @@
11
11
  import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
12
  import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
+ import { recordAttempt } from "../utils/attempt-stats";
14
15
  import { generateEventId } from "../utils/uuid";
15
- import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
16
+ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
16
17
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
17
18
  const MAX_RETRIES = 5;
18
19
  const QUEUED_STATUS_BY_COMMAND = {
@@ -25,6 +26,10 @@ const QUEUED_STATUS_BY_COMMAND = {
25
26
  "delivery:assign": "assigned",
26
27
  "delivery:confirm_handover": "delivered",
27
28
  };
29
+ const PAYMENT_QUEUED_PAYLOAD = {
30
+ is_paid: true,
31
+ to_is_paid: true,
32
+ };
28
33
  export function getQueuedStatus(commandType) {
29
34
  return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
30
35
  }
@@ -42,6 +47,24 @@ function buildOptimisticEvent(command) {
42
47
  timestamp: new Date().toISOString(),
43
48
  };
44
49
  }
50
+ if (command.command_type === "payment:confirm_cash") {
51
+ return {
52
+ event_id: generateEventId(),
53
+ event_type: "payment.completed_queued",
54
+ event_version: "1.0",
55
+ entity_id: command.entity_id ?? command.command_id,
56
+ user_id: command.user_id,
57
+ device_id: command.device_id,
58
+ payload: {
59
+ ...payload,
60
+ command_id: command.command_id,
61
+ is_paid: true,
62
+ paid_at: new Date().toISOString(),
63
+ order_id: command.entity_id,
64
+ },
65
+ timestamp: new Date().toISOString(),
66
+ };
67
+ }
45
68
  const queuedStatus = getQueuedStatus(command.command_type);
46
69
  return {
47
70
  event_id: generateEventId(),
@@ -58,11 +81,13 @@ function buildOptimisticEvent(command) {
58
81
  to_status: queuedStatus,
59
82
  order_id: command.entity_id,
60
83
  delivery_id: command.entity_id,
84
+ ...(queuedStatus ? {} : {}),
61
85
  },
62
86
  timestamp: new Date().toISOString(),
63
87
  };
64
88
  }
65
89
  function buildOptimisticOrder(command, payload) {
90
+ const snapshots = payload.item_snapshots ?? null;
66
91
  const items = payload.items ?? [];
67
92
  const totalMinor = payload.total_minor ?? 0;
68
93
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
@@ -74,14 +99,14 @@ function buildOptimisticOrder(command, payload) {
74
99
  return {
75
100
  id: command.entity_id ?? command.command_id,
76
101
  order_number: orderNumber,
77
- callout_number: generateLocalCalloutNumber(),
102
+ callout_number: generateLocalCalloutNumber(command.business_id),
78
103
  order_type: payload.order_type ?? "takeout",
79
104
  is_pos: true,
80
105
  customer_name: payload.customer_name ?? null,
81
106
  customer_phone: payload.customer_phone ?? null,
82
107
  pos_payment_method: payload.pos_payment_method ?? "cash",
83
108
  paid_at: null,
84
- status: "pending",
109
+ status: "accepted",
85
110
  delivery_id: null,
86
111
  delivery_status: null,
87
112
  is_paid: payload.pos_payment_method === "cash",
@@ -109,14 +134,22 @@ function buildOptimisticOrder(command, payload) {
109
134
  delivery_lat: null,
110
135
  delivery_lng: null,
111
136
  user: null,
112
- items: items.map((item, index) => ({
137
+ items: (snapshots ? snapshots.map((s, index) => ({
138
+ id: `tmp-${command.command_id}-${index}`,
139
+ menu_item: { id: s.menu_item_id, name: s.name },
140
+ quantity: s.quantity,
141
+ unit_price: s.unit_price_minor,
142
+ subtotal: s.unit_price_minor * s.quantity,
143
+ variant_name: s.variant_name,
144
+ addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
145
+ })) : items.map((item, index) => ({
113
146
  id: `tmp-${command.command_id}-${index}`,
114
147
  menu_item: { id: item.menu_item_id, name: "" },
115
148
  quantity: item.quantity,
116
149
  unit_price: 0,
117
150
  subtotal: 0,
118
151
  addons: [],
119
- })),
152
+ }))),
120
153
  inserted_at: now,
121
154
  };
122
155
  }
@@ -142,7 +175,7 @@ function injectLocalOrderNumber(command) {
142
175
  }
143
176
  class CommandQueueService {
144
177
  constructor() {
145
- this.replaying = false;
178
+ this.replaying = null;
146
179
  this.syncEngine = null;
147
180
  }
148
181
  setSyncEngine(engine) {
@@ -222,7 +255,7 @@ class CommandQueueService {
222
255
  }
223
256
  await markCommandInFlight(db, command.command_id);
224
257
  try {
225
- const events = await this.syncEngine.execute(parsedCommand);
258
+ const events = await this.syncEngine.execute(parsedCommand, { forReplay: true });
226
259
  // Outbox is an ephemeral queue — remove the row on success so the
227
260
  // Diagnostics outbox count drops to 0 and old completed rows never
228
261
  // accumulate. The optimistic event stays in `events` (marked synced).
@@ -238,11 +271,18 @@ class CommandQueueService {
238
271
  }
239
272
  catch (error) {
240
273
  const message = error instanceof Error ? error.message : String(error);
274
+ // Replay with no transport available: leave the row exactly as-is for
275
+ // the next trigger (do not mark, do not count). Must reset the
276
+ // in_flight flag first — rows stuck in_flight are invisible to the
277
+ // outbox query and would wedge the queue forever.
278
+ if (message.includes("ReplayDeferred")) {
279
+ await updateCommandStatus(db, command.command_id, "pending", "deferred:no-transport");
280
+ throw error;
281
+ }
241
282
  const apiErr = error;
242
283
  // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR
243
284
  const code = apiErr?.code ?? apiErr?.data?.code;
244
285
  if (apiErr?.status === 409 && String(code).includes("ORDER_NUMBER_TAKEN")) {
245
- // Regenerate random part of order_number if this was order:create
246
286
  if (command.command_type === "order:create") {
247
287
  try {
248
288
  const payload = command.payload;
@@ -250,12 +290,14 @@ class CommandQueueService {
250
290
  const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
251
291
  if (parsed) {
252
292
  const deviceNumber = parsed[1];
253
- const { generateOrderNumber } = await import("./order-number");
254
- const { orderNumber: newOn } = generateOrderNumber(deviceNumber);
293
+ const { orderNumber: newOn } = generateOrderNumber(deviceNumber, command.business_id);
255
294
  const newPayload = { ...payload, order_number: newOn };
256
- await updateCommandStatus(db, command.command_id, "failed", `retry_random:${message}`);
257
- // Update stored payload for retry
258
- await db.runAsync(`UPDATE commands SET payload = ?, retry_count = retry_count WHERE command_id = ?;`, JSON.stringify(newPayload), command.command_id);
295
+ await db.runAsync(`UPDATE commands SET payload = ?, error = ?, updated_at = ? WHERE command_id = ?;`, JSON.stringify(newPayload), `retry_random:${message}`, new Date().toISOString(), command.command_id);
296
+ syncLogger.info("CommandQueueService", "Regenerated order_number after 409", { command_id: command.command_id, new_order_number: newOn });
297
+ // keep retry_count unchanged so next replay retries; do not mark failed twice.
298
+ // Reset in_flight → pending so the row stays visible to the outbox query.
299
+ await updateCommandStatus(db, command.command_id, "pending", `retry_random:${message}`);
300
+ return;
259
301
  }
260
302
  }
261
303
  catch { }
@@ -275,11 +317,29 @@ class CommandQueueService {
275
317
  status: apiErr.status,
276
318
  error: message,
277
319
  });
320
+ // Loud toast + revert: the optimistic local write is rolled back so
321
+ // the UI never shows what the backend refused.
322
+ try {
323
+ const { notifyError } = await import("../adapters/notify");
324
+ notifyError(`Server rejected ${command.command_type}: ${message}`);
325
+ }
326
+ catch { }
327
+ try {
328
+ if (command.command_type.startsWith("menu:") && command.entity_id) {
329
+ const { revertMenuCommand } = await import("../reference/menu-apply");
330
+ await revertMenuCommand(command.business_id, command.command_type, command.entity_id);
331
+ }
332
+ }
333
+ catch { }
278
334
  // Do not throw — allow replay to continue to next command.
279
335
  return;
280
336
  }
281
- await updateCommandStatus(db, command.command_id, "failed", message);
282
- syncLogger.command(parsedCommand, "failed", { error: message });
337
+ // Network/timeout/5xx: NOT a rejection — keep the command retryable
338
+ // indefinitely (status back to pending, retry_count untouched) so a
339
+ // long outage never dead-letters the outbox via MAX_RETRIES. Replay
340
+ // stops here to preserve ordering; the next trigger retries.
341
+ await updateCommandStatus(db, command.command_id, "pending", `network:${message}`);
342
+ syncLogger.command(parsedCommand, "queued", { error: message, deferred: true });
283
343
  throw error;
284
344
  }
285
345
  }
@@ -288,11 +348,21 @@ class CommandQueueService {
288
348
  * ordering consistent; the next replay will retry from the failed command.
289
349
  */
290
350
  async replay() {
351
+ recordAttempt("sync_drain", "replay");
352
+ // Join an in-flight replay instead of no-op'ing: concurrent replays
353
+ // (connectivity auto-replay racing an explicit replay) must converge on
354
+ // the same outcome, otherwise callers observe a drained queue while work
355
+ // is still flying.
291
356
  if (this.replaying) {
292
- syncLogger.debug("CommandQueueService", "Replay already in progress");
357
+ syncLogger.debug("CommandQueueService", "Replay already in progress, joining");
358
+ try {
359
+ await this.replaying;
360
+ }
361
+ catch { }
293
362
  return;
294
363
  }
295
- this.replaying = true;
364
+ let resolveReplay;
365
+ this.replaying = new Promise((r) => { resolveReplay = r; });
296
366
  try {
297
367
  // Await open (not getSyncDatabase) so a cold-start replay triggered by a
298
368
  // connectivity event that fires before the DB open completes does not
@@ -324,7 +394,11 @@ class CommandQueueService {
324
394
  }
325
395
  }
326
396
  finally {
327
- this.replaying = false;
397
+ this.replaying = null;
398
+ try {
399
+ resolveReplay();
400
+ }
401
+ catch { }
328
402
  }
329
403
  }
330
404
  /**
@@ -345,5 +419,28 @@ class CommandQueueService {
345
419
  const commands = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
346
420
  return commands.length;
347
421
  }
422
+ /**
423
+ * Debug dump of the engine outbox for Diagnostics screens (all statuses,
424
+ * newest last). Mirrors debugDumpSyncJobs.
425
+ */
426
+ async debugDumpCommands(businessId) {
427
+ try {
428
+ const db = getSyncDatabase();
429
+ const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
430
+ return rows
431
+ .filter((c) => !businessId || c.business_id === businessId)
432
+ .map((c) => ({
433
+ id: c.command_id,
434
+ command_type: c.command_type,
435
+ status: c.status,
436
+ retry_count: c.retry_count,
437
+ error: c.error ?? null,
438
+ created_at: c.created_at ?? "",
439
+ }));
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ }
348
445
  }
349
446
  export const commandQueueService = new CommandQueueService();
@@ -3,7 +3,7 @@
3
3
  * Backend authority: POST /businesses/:id/devices/lease
4
4
  * KV persists device_number per businessId.
5
5
  */
6
- declare function getAppInstanceId(): string;
6
+ import { getAppInstanceIdAsync } from "../adapters/devices";
7
7
  export declare function getDeviceNumber(businessId: string): string | null;
8
8
  export declare function isOnlineOnly(businessId: string): boolean;
9
9
  export declare function ensureLease(businessId: string): Promise<string | null>;
@@ -14,6 +14,6 @@ export declare const deviceLeaseService: {
14
14
  isOnlineOnly: typeof isOnlineOnly;
15
15
  ensureLease: typeof ensureLease;
16
16
  refreshLease: typeof refreshLease;
17
- getAppInstanceId: typeof getAppInstanceId;
17
+ getAppInstanceId: typeof getAppInstanceIdAsync;
18
+ getAppInstanceIdAsync: typeof getAppInstanceIdAsync;
18
19
  };
19
- export {};