@teincfood/core 0.7.9 → 0.8.1

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 (191) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +8 -0
  3. package/dist/adapters/auth.js +36 -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 +96 -15
  16. package/dist/adapters/services.js +241 -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 +24 -0
  47. package/dist/hooks/image.hooks.js +66 -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 +18 -39
  55. package/dist/hooks/orders.hooks.js +124 -343
  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 +28 -0
  67. package/dist/image-cache/service.js +51 -3
  68. package/dist/index.d.ts +56 -6
  69. package/dist/index.js +73 -6
  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/phoenix-socket.js +30 -1
  88. package/dist/realtime/useEventsConnection.js +11 -2
  89. package/dist/reference/api.d.ts +2 -0
  90. package/dist/reference/api.js +2 -0
  91. package/dist/reference/capabilities.d.ts +12 -7
  92. package/dist/reference/capabilities.js +27 -13
  93. package/dist/reference/hooks.d.ts +34 -0
  94. package/dist/reference/hooks.js +124 -0
  95. package/dist/reference/menu-apply.d.ts +29 -0
  96. package/dist/reference/menu-apply.js +289 -0
  97. package/dist/reference/menu-record.d.ts +69 -0
  98. package/dist/reference/menu-record.js +168 -0
  99. package/dist/reference/operations.d.ts +3 -0
  100. package/dist/reference/operations.js +35 -1
  101. package/dist/reference/service.js +36 -9
  102. package/dist/reference/types.d.ts +1 -1
  103. package/dist/repositories/order.repository.d.ts +7 -0
  104. package/dist/repositories/order.repository.js +15 -2
  105. package/dist/repositories/pos.repository.d.ts +4 -0
  106. package/dist/repositories/pos.repository.js +4 -0
  107. package/dist/sync/backoff.d.ts +21 -0
  108. package/dist/sync/backoff.js +42 -0
  109. package/dist/sync/command-builder.js +10 -2
  110. package/dist/sync/command-queue.service.d.ts +12 -0
  111. package/dist/sync/command-queue.service.js +81 -16
  112. package/dist/sync/device-lease.service.d.ts +3 -3
  113. package/dist/sync/device-lease.service.js +80 -20
  114. package/dist/sync/engine.d.ts +3 -1
  115. package/dist/sync/engine.js +44 -4
  116. package/dist/sync/full-sync.d.ts +6 -0
  117. package/dist/sync/full-sync.js +72 -0
  118. package/dist/sync/health-probe.d.ts +24 -0
  119. package/dist/sync/health-probe.js +138 -0
  120. package/dist/sync/offline-capable.js +25 -0
  121. package/dist/sync/online-gates.d.ts +17 -0
  122. package/dist/sync/online-gates.js +55 -0
  123. package/dist/sync/order-number.d.ts +4 -4
  124. package/dist/sync/order-number.js +33 -13
  125. package/dist/sync/order-snapshots.service.js +18 -3
  126. package/dist/sync/pending-orders.service.d.ts +18 -0
  127. package/dist/sync/pending-orders.service.js +67 -1
  128. package/dist/sync/reconciliation/index.d.ts +5 -0
  129. package/dist/sync/reconciliation/index.js +5 -0
  130. package/dist/sync/snapshots/entity.d.ts +6 -0
  131. package/dist/sync/snapshots/entity.js +6 -0
  132. package/dist/sync/sync-jobs.service.d.ts +18 -0
  133. package/dist/sync/sync-jobs.service.js +539 -0
  134. package/dist/sync/transition-endpoints.d.ts +11 -0
  135. package/dist/sync/transition-endpoints.js +30 -0
  136. package/dist/sync/transport-local-node.js +3 -2
  137. package/dist/sync/transport-rest.d.ts +7 -0
  138. package/dist/sync/transport-rest.js +84 -26
  139. package/dist/sync/use-sync-reconciliation.js +15 -1
  140. package/dist/test-helpers/memory-driver.js +182 -4
  141. package/dist/test-helpers/seed-hours.d.ts +1 -0
  142. package/dist/test-helpers/seed-hours.js +23 -0
  143. package/dist/types/api/business.api.d.ts +42 -0
  144. package/dist/types/api/menu.api.d.ts +15 -0
  145. package/dist/types/api/seller.api.d.ts +20 -0
  146. package/dist/types/places.d.ts +19 -0
  147. package/dist/types/places.js +2 -0
  148. package/dist/types/rider.d.ts +67 -0
  149. package/dist/types/rider.js +2 -0
  150. package/dist/utils/attempt-stats.d.ts +23 -0
  151. package/dist/utils/attempt-stats.js +61 -0
  152. package/dist/utils/business-hours.d.ts +37 -0
  153. package/dist/utils/business-hours.js +96 -0
  154. package/dist/utils/debug-log.d.ts +23 -0
  155. package/dist/utils/debug-log.js +153 -0
  156. package/dist/utils/order-filter.d.ts +1 -1
  157. package/dist/utils/order-filter.js +1 -1
  158. package/dist/utils/order-transitions.d.ts +30 -0
  159. package/dist/utils/order-transitions.js +185 -0
  160. package/dist/utils/sync-logger.js +5 -4
  161. package/dist/validation/offline.d.ts +46 -0
  162. package/dist/validation/offline.js +79 -0
  163. package/package.json +6 -1
  164. package/dist/pricing/engine.test.d.ts +0 -1
  165. package/dist/pricing/engine.test.js +0 -142
  166. package/dist/reference/lww.test.d.ts +0 -1
  167. package/dist/reference/lww.test.js +0 -23
  168. package/dist/reference/operations.test.d.ts +0 -1
  169. package/dist/reference/operations.test.js +0 -66
  170. package/dist/standalone.integration.test.d.ts +0 -1
  171. package/dist/standalone.integration.test.js +0 -235
  172. package/dist/sync/device-lease.test.d.ts +0 -1
  173. package/dist/sync/device-lease.test.js +0 -38
  174. package/dist/sync/offline-integration.test.d.ts +0 -1
  175. package/dist/sync/offline-integration.test.js +0 -52
  176. package/dist/sync/order-number.test.d.ts +0 -1
  177. package/dist/sync/order-number.test.js +0 -51
  178. package/dist/sync/perms.test.d.ts +0 -1
  179. package/dist/sync/perms.test.js +0 -15
  180. package/dist/utils/currency.test.d.ts +0 -1
  181. package/dist/utils/currency.test.js +0 -17
  182. package/dist/utils/hlc.test.d.ts +0 -1
  183. package/dist/utils/hlc.test.js +0 -29
  184. package/dist/utils/phone.test.d.ts +0 -1
  185. package/dist/utils/phone.test.js +0 -19
  186. package/dist/utils/responsive.d.ts +0 -13
  187. package/dist/utils/responsive.js +0 -18
  188. package/dist/utils/uuid.test.d.ts +0 -1
  189. package/dist/utils/uuid.test.js +0 -13
  190. package/dist/utils/validation.test.d.ts +0 -1
  191. package/dist/utils/validation.test.js +0 -23
@@ -126,12 +126,22 @@ export async function upsertEntitySnapshot(db, input) {
126
126
  last_event_timestamp = excluded.last_event_timestamp,
127
127
  payload = excluded.payload;
128
128
  `, entityType, entityId, businessId, lastEventId, lastEventTimestamp, JSON.stringify(payload));
129
+ notifyDbChange(businessId ?? undefined, `entity_snapshots:${entityType}`);
129
130
  }
130
- export async function getEntitySnapshot(db, entityType, entityId) {
131
+ export async function getEntitySnapshot(db, entityType, entityId, businessId) {
132
+ if (businessId) {
133
+ const row = await db.getFirstAsync("SELECT * FROM entity_snapshots WHERE entity_type = ? AND entity_id = ? AND business_id = ?;", entityType, entityId, businessId);
134
+ if (row)
135
+ return rowToEntitySnapshot(row);
136
+ }
131
137
  const row = await db.getFirstAsync("SELECT * FROM entity_snapshots WHERE entity_type = ? AND entity_id = ?;", entityType, entityId);
132
138
  return row ? rowToEntitySnapshot(row) : null;
133
139
  }
134
- export async function getEntitySnapshotsByType(db, entityType) {
140
+ export async function getEntitySnapshotsByType(db, entityType, businessId) {
141
+ if (businessId) {
142
+ const rows = await db.getAllAsync("SELECT * FROM entity_snapshots WHERE entity_type = ? AND business_id = ? ORDER BY last_event_timestamp DESC;", entityType, businessId);
143
+ return rows.map(rowToEntitySnapshot);
144
+ }
135
145
  const rows = await db.getAllAsync("SELECT * FROM entity_snapshots WHERE entity_type = ? ORDER BY last_event_timestamp DESC;", entityType);
136
146
  return rows.map(rowToEntitySnapshot);
137
147
  }
@@ -173,20 +183,179 @@ function rowToDomainEvent(row) {
173
183
  timestamp: row.timestamp,
174
184
  };
175
185
  }
186
+ function orderRowToSellerOrder(row) {
187
+ return {
188
+ id: row.id,
189
+ business_id: row.business_id,
190
+ order_number: row.order_number,
191
+ callout_number: row.callout_number,
192
+ status: row.status,
193
+ order_type: row.order_type,
194
+ is_pos: !!row.is_pos,
195
+ items: row.items ? JSON.parse(row.items) : [],
196
+ subtotal_minor: row.subtotal_minor,
197
+ total_minor: row.total_minor,
198
+ tax_minor: row.tax_minor,
199
+ tax_lines: row.tax_lines ? JSON.parse(row.tax_lines) : [],
200
+ payment: row.payment ? JSON.parse(row.payment) : { is_paid: false, reference: null, amount_minor: null, currency: null, paid_at: null },
201
+ customer_name: row.customer_name,
202
+ customer_phone: row.customer_phone,
203
+ pos_payment_method: row.pos_payment_method,
204
+ delivery_id: row.delivery_id,
205
+ business: row.business ? JSON.parse(row.business) : null,
206
+ delivery_address: null,
207
+ delivery_lat: null,
208
+ delivery_lng: null,
209
+ user: row.user ? JSON.parse(row.user) : null,
210
+ currency: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
211
+ currency_code: (row.payment ? JSON.parse(row.payment).currency : "GHS") ?? "GHS",
212
+ discount_minor: 0,
213
+ promo_code: null,
214
+ tip_minor: null,
215
+ delivery_fee_minor: 0,
216
+ delivery_status: null,
217
+ is_paid: row.payment ? JSON.parse(row.payment).is_paid ?? false : false,
218
+ paid_at: row.payment ? JSON.parse(row.payment).paid_at ?? null : null,
219
+ inserted_at: row.inserted_at,
220
+ updated_at: row.updated_at ?? row.inserted_at,
221
+ };
222
+ }
223
+ export async function insertOrder(db, order) {
224
+ const hlc = order.hlc ?? new Date().toISOString();
225
+ const updatedAt = order.updated_at ?? order.inserted_at;
226
+ 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)
227
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
228
+ 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
229
+ 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);
230
+ notifyDbChange(order.business_id, "orders");
231
+ }
232
+ export async function getOrderById(db, businessId, idOrNumber) {
233
+ const row = await db.getFirstAsync(`SELECT * FROM orders WHERE business_id = ? AND (id = ? OR order_number = ?) LIMIT 1;`, businessId, idOrNumber, idOrNumber);
234
+ return row ? orderRowToSellerOrder(row) : null;
235
+ }
236
+ export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
237
+ const where = ["business_id = ?"];
238
+ const args = [businessId];
239
+ if (filters?.status) {
240
+ const statuses = filters.status.split(",").map((s) => s.trim()).filter(Boolean);
241
+ if (statuses.length) {
242
+ where.push(`status IN (${statuses.map(() => "?").join(",")})`);
243
+ args.push(...statuses);
244
+ }
245
+ }
246
+ if (filters?.is_pos != null) {
247
+ where.push("is_pos = ?");
248
+ args.push(filters.is_pos === "true" || filters.is_pos === "1" ? 1 : 0);
249
+ }
250
+ if (filters?.order_type) {
251
+ where.push("order_type = ?");
252
+ args.push(filters.order_type);
253
+ }
254
+ if (filters?.q) {
255
+ where.push("(order_number LIKE ? OR customer_name LIKE ? OR customer_phone LIKE ?)");
256
+ const q = `%${filters.q}%`;
257
+ args.push(q, q, q);
258
+ }
259
+ if (filters?.date_from) {
260
+ where.push("inserted_at >= ?");
261
+ args.push(filters.date_from);
262
+ }
263
+ if (filters?.date_to) {
264
+ where.push("inserted_at < ?");
265
+ args.push(`${filters.date_to}T23:59:59`);
266
+ }
267
+ const whereSql = where.join(" AND ");
268
+ const countRow = await db.getFirstAsync(`SELECT COUNT(*) as c FROM orders WHERE ${whereSql};`, ...args);
269
+ const total = countRow?.c ?? 0;
270
+ const offset = (page - 1) * pageSize;
271
+ const rows = await db.getAllAsync(`SELECT * FROM orders WHERE ${whereSql} ORDER BY inserted_at DESC LIMIT ? OFFSET ?;`, ...args, pageSize, offset);
272
+ return { data: rows.map(orderRowToSellerOrder), total };
273
+ }
274
+ export async function updateOrderStatus(db, businessId, orderId, toStatus, hlc) {
275
+ const now = hlc ?? new Date().toISOString();
276
+ await db.runAsync(`UPDATE orders SET status = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, toStatus, now, businessId, orderId, orderId);
277
+ notifyDbChange(businessId, "orders");
278
+ }
279
+ export async function updateOrderPayment(db, businessId, orderId, patch) {
280
+ const row = await getOrderById(db, businessId, orderId);
281
+ if (!row)
282
+ return;
283
+ const payment = { ...row.payment, is_paid: patch.is_paid, paid_at: patch.paid_at };
284
+ 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);
285
+ notifyDbChange(businessId, "orders");
286
+ }
176
287
  // ─────────────────────────────────────────────────────────────
177
288
  // Maintenance — clear stale local data (used by Diagnostics)
178
289
  // ─────────────────────────────────────────────────────────────
179
290
  export async function clearAllSyncData(db) {
180
- await db.execAsync(`DELETE FROM commands;`);
181
- await db.execAsync(`DELETE FROM events;`);
182
- await db.execAsync(`DELETE FROM entity_snapshots;`);
183
- // Keep reference_data — it is business-scoped and expensive to re-fetch;
184
- // only order/command state is cleared. If full wipe needed, call
185
- // clearReferenceData below.
291
+ // New-DB primary: orders + sync_jobs. Keep legacy deletes guarded (v8 dropped them).
292
+ try {
293
+ await db.execAsync(`DELETE FROM orders;`);
294
+ }
295
+ catch { }
296
+ try {
297
+ await db.execAsync(`DELETE FROM sync_jobs;`);
298
+ }
299
+ catch { }
300
+ try {
301
+ await db.execAsync(`DELETE FROM entity_snapshots;`);
302
+ }
303
+ catch { }
304
+ try {
305
+ await db.execAsync(`DELETE FROM commands;`);
306
+ }
307
+ catch { }
308
+ try {
309
+ await db.execAsync(`DELETE FROM events;`);
310
+ }
311
+ catch { }
312
+ // Keep reference_data by default (expensive to re-fetch). Use wipeForNewInstall for full reset.
313
+ }
314
+ export async function wipeForNewInstall(db) {
315
+ try {
316
+ await db.execAsync(`DELETE FROM orders;`);
317
+ }
318
+ catch { }
319
+ try {
320
+ await db.execAsync(`DELETE FROM sync_jobs;`);
321
+ }
322
+ catch { }
323
+ try {
324
+ await db.execAsync(`DELETE FROM entity_snapshots;`);
325
+ }
326
+ catch { }
327
+ try {
328
+ await db.execAsync(`DELETE FROM reference_data;`);
329
+ }
330
+ catch { }
331
+ try {
332
+ await db.execAsync(`DELETE FROM commands;`);
333
+ }
334
+ catch { }
335
+ try {
336
+ await db.execAsync(`DELETE FROM events;`);
337
+ }
338
+ catch { }
339
+ try {
340
+ await db.execAsync(`DELETE FROM migrations WHERE version >= 5;`);
341
+ }
342
+ catch { }
186
343
  }
187
344
  export async function clearReferenceData(db) {
188
345
  await db.execAsync(`DELETE FROM reference_data;`);
189
346
  }
347
+ const dbChangeListeners = new Set();
348
+ export function subscribeDbChanges(listener) {
349
+ dbChangeListeners.add(listener);
350
+ return () => dbChangeListeners.delete(listener);
351
+ }
352
+ export function notifyDbChange(businessId, table) {
353
+ for (const l of dbChangeListeners)
354
+ try {
355
+ l({ businessId, table });
356
+ }
357
+ catch { }
358
+ }
190
359
  export async function clearCompletedCommandsAndOrphans(db) {
191
360
  await db.execAsync(`DELETE FROM commands WHERE status = 'completed';`);
192
361
  try {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Business — active business isolation (§8), capabilities, profile.
3
+ */
4
+ export * from "../../adapters/business";
5
+ export * from "../../reference/capabilities";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Business — active business isolation (§8), capabilities, profile.
3
+ */
4
+ export * from "../../adapters/business";
5
+ export * from "../../reference/capabilities";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Food order lifecycle — explicit transitions, payment independent (§19).
3
+ * Single source: src/utils/order-transitions. Re-export for domain structure.
4
+ */
5
+ export * from "../../utils/order-transitions";
6
+ export { orderRepository } from "../../repositories/order.repository";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Food order lifecycle — explicit transitions, payment independent (§19).
3
+ * Single source: src/utils/order-transitions. Re-export for domain structure.
4
+ */
5
+ export * from "../../utils/order-transitions";
6
+ export { orderRepository } from "../../repositories/order.repository";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Local Orders Service — SQLite is real DB (like backend Orders).
3
+ * Device-partitioned by UUID PK + DD-RRR-CCC per business.
4
+ * is_pos orders are accepted at create; online payment requires online.
5
+ */
6
+ import type { SellerOrder } from "../../types/api/seller.api";
7
+ import type { POSOrderCreateRequest } from "../../types/pos.types";
8
+ export declare function createLocalOrder(businessId: string, input: POSOrderCreateRequest & {
9
+ is_pos?: boolean;
10
+ delivery_params?: Record<string, unknown>;
11
+ }): Promise<SellerOrder>;
12
+ export declare function transitionLocalOrder(businessId: string, orderId: string, action: string): Promise<SellerOrder>;
13
+ export declare function listLocalOrders(businessId: string, filters?: {
14
+ status?: string;
15
+ q?: string;
16
+ is_pos?: string;
17
+ order_type?: string;
18
+ date_from?: string;
19
+ date_to?: string;
20
+ }, page?: number, pageSize?: number): Promise<{
21
+ data: import("../../types/api/seller.api").SellerOrder[];
22
+ total: number;
23
+ }>;
24
+ export declare function getLocalOrder(businessId: string, idOrNumber: string): Promise<SellerOrder | null>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Local Orders Service — SQLite is real DB (like backend Orders).
3
+ * Device-partitioned by UUID PK + DD-RRR-CCC per business.
4
+ * is_pos orders are accepted at create; online payment requires online.
5
+ */
6
+ import { openSyncDatabase } from "../../db/connection";
7
+ import { insertOrder, getOrderById, listOrders, updateOrderStatus, updateOrderPayment, getEntitySnapshotsByType } from "../../db/operations";
8
+ import { getDeviceNumber, ensureLease } from "../../sync/device-lease.service";
9
+ import { generateOrderNumberForBusiness } from "../../sync/order-number";
10
+ import { generateUUID } from "../../utils/uuid";
11
+ import { getConnectivityState } from "../../adapters/connectivity";
12
+ import { assertOnlineFor } from "../../sync/online-gates";
13
+ import { assertBusinessOpenForCreate } from "../../utils/business-hours";
14
+ import { canTransition } from "../../utils/order-transitions";
15
+ import { validateOrderCreateInput } from "../../validation/offline";
16
+ import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
17
+ import { getAuthToken } from "../../adapters/auth";
18
+ import { syncLogger } from "../../utils/sync-logger";
19
+ import { getItem } from "../../adapters/kv";
20
+ let backfilled = new Set();
21
+ async function ensureOrdersBackfilledFromSnapshots(businessId) {
22
+ if (backfilled.has(businessId))
23
+ return;
24
+ try {
25
+ const db = await openSyncDatabase();
26
+ const cnt = await db.getFirstAsync(`SELECT COUNT(*) as c FROM orders WHERE business_id=?;`, businessId);
27
+ if ((cnt?.c ?? 0) > 0) {
28
+ backfilled.add(businessId);
29
+ return;
30
+ }
31
+ const snaps = await getEntitySnapshotsByType(db, "seller_orders", businessId);
32
+ if (!snaps.length) {
33
+ backfilled.add(businessId);
34
+ return;
35
+ }
36
+ const seen = new Set();
37
+ for (const s of snaps) {
38
+ const arr = s.payload?.data ?? [];
39
+ for (const o of arr) {
40
+ if (!o?.id || !o.order_number || seen.has(o.id))
41
+ continue;
42
+ seen.add(o.id);
43
+ try {
44
+ await insertOrder(db, o);
45
+ }
46
+ catch { }
47
+ }
48
+ }
49
+ if (seen.size > 0) {
50
+ try {
51
+ console.debug("[core] backfilled orders from seller_orders snapshots", { businessId, count: seen.size });
52
+ }
53
+ catch { }
54
+ }
55
+ backfilled.add(businessId);
56
+ }
57
+ catch { }
58
+ }
59
+ function isEffectivelyOnline() {
60
+ const s = getConnectivityState();
61
+ // Health probe failure means backend unreachable — treat as offline for order gates too
62
+ return s.isOnline && s.isCloudReachable !== "unreachable";
63
+ }
64
+ function isOnline() {
65
+ return isEffectivelyOnline();
66
+ }
67
+ export async function createLocalOrder(businessId, input) {
68
+ const isPos = true; // POS terminal always is_pos
69
+ // Online-required gates (single source: sync/online-gates). Cash needs no
70
+ // connectivity; prepaid/mobile-money and delivery do.
71
+ assertOnlineFor("order:create", input);
72
+ // Offline validation mirror — backend Order.changeset rules, pre-commit.
73
+ validateOrderCreateInput(input);
74
+ const method = input.pos_payment_method ?? "cash";
75
+ // Operating-hours gate (fail-closed, offline-first): matches backend
76
+ // validate_business_open. Throws BUSINESS_CLOSED / HOURS_NOT_SYNCED before
77
+ // anything is written, so a doomed order never enters SQLite/sync_jobs.
78
+ await assertBusinessOpenForCreate(businessId);
79
+ // DD lease required (backend lease, not local alloc) — keep leased DD even offline
80
+ let dd = getDeviceNumber(businessId);
81
+ if (!dd) {
82
+ // Boot race: sync KV may not yet have hydrated from kv.json — try async before blocking
83
+ try {
84
+ const asyncDd = await getItem(`teinc_device_number:${businessId}`);
85
+ if (asyncDd && /^(0[1-9]|[1-9][0-9])$/.test(asyncDd))
86
+ dd = asyncDd;
87
+ }
88
+ catch { }
89
+ }
90
+ if (!dd) {
91
+ const leased = await ensureLease(businessId);
92
+ if (leased)
93
+ dd = leased;
94
+ }
95
+ if (!dd)
96
+ throw new Error("No device number leased (99 max) — connect online once to lease DD");
97
+ const id = (input.id ?? input.command_id ?? generateUUID());
98
+ const orderNumber = input.order_number?.toString() ?? generateOrderNumberForBusiness(businessId);
99
+ const callout = parseInt(orderNumber.split("-")[2] ?? "0", 10) || 0;
100
+ // Use client-provided timestamps for offline-first sync so records created
101
+ // while offline retain their original creation time.
102
+ const now = new Date().toISOString();
103
+ const insertedAt = input.inserted_at || now;
104
+ // item_snapshots from CartPanel, else fallback
105
+ const snapshots = input.item_snapshots;
106
+ const items = snapshots ? snapshots.map((s, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: s.menu_item_id, name: s.name }, quantity: s.quantity, unit_price: s.unit_price_minor, subtotal: s.unit_price_minor * s.quantity })) : (input.items ?? []).map((it, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: it.menu_item_id, name: "" }, quantity: it.quantity, unit_price: 0, subtotal: 0 }));
107
+ const totalMinor = input.total_minor ?? 0;
108
+ const subtotalMinor = input.subtotal_minor ?? totalMinor;
109
+ const taxMinor = input.tax_minor ?? 0;
110
+ const taxLines = input.tax_lines ?? [];
111
+ const currencyCode = input.currency_code ?? "GHS";
112
+ const order = {
113
+ id,
114
+ order_number: orderNumber,
115
+ callout_number: callout,
116
+ status: isPos ? "accepted" : "pending",
117
+ order_type: input.order_type ?? "takeout",
118
+ is_pos: isPos,
119
+ customer_name: input.customer_name ?? null,
120
+ customer_phone: input.customer_phone ?? null,
121
+ pos_payment_method: method,
122
+ paid_at: method === "cash" ? insertedAt : null,
123
+ is_paid: method === "cash",
124
+ payment: { is_paid: method === "cash", reference: null, amount_minor: totalMinor, currency: currencyCode, paid_at: method === "cash" ? insertedAt : null },
125
+ payment_mode: method === "cash" ? "pos_cash" : "prepaid",
126
+ total_minor: totalMinor,
127
+ subtotal_minor: subtotalMinor,
128
+ delivery_fee_minor: input.delivery_fee_minor ?? 0,
129
+ tax_minor: taxMinor,
130
+ tip_minor: null,
131
+ discount_minor: 0,
132
+ promo_code: null,
133
+ tax_lines: taxLines,
134
+ currency: currencyCode,
135
+ currency_code: currencyCode,
136
+ business_id: businessId,
137
+ business: null,
138
+ delivery_address: null,
139
+ delivery_lat: null,
140
+ delivery_lng: null,
141
+ delivery_id: null,
142
+ delivery_status: null,
143
+ user: null,
144
+ items: items,
145
+ inserted_at: insertedAt,
146
+ };
147
+ const db = await openSyncDatabase();
148
+ await insertOrder(db, order);
149
+ syncLogger.debug("DomainOrders", "createLocalOrder inserted", { businessId, id, orderNumber, isPos, connectivity: getConnectivityState(), hasToken: !!getAuthToken(), deviceNumber: dd });
150
+ try {
151
+ 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);
152
+ syncLogger.debug("DomainOrders", "sync_jobs enqueued", { businessId, id, command_type: "order:create" });
153
+ }
154
+ catch (e) {
155
+ syncLogger.debug("DomainOrders", "sync_jobs enqueue fail", { err: String(e) });
156
+ }
157
+ syncLogger.debug("DomainOrders", "createLocalOrder triggering drainOne", { businessId, id });
158
+ void drainOne(businessId, id).catch((e) => syncLogger.debug("DomainOrders", "drainOne after create caught", { err: String(e) }));
159
+ return order;
160
+ }
161
+ export async function transitionLocalOrder(businessId, orderId, action) {
162
+ const db = await openSyncDatabase();
163
+ const order = await getOrderById(db, businessId, orderId);
164
+ if (!order)
165
+ throw new Error("Order not found");
166
+ // Delivery transitions require online (single source: sync/online-gates).
167
+ if (action === "assign_rider")
168
+ assertOnlineFor("order:assign_rider");
169
+ else if (action === "confirm_handover")
170
+ assertOnlineFor("order:confirm_handover");
171
+ else if (order.order_type === "delivery")
172
+ assertOnlineFor("order:create", { order_type: "delivery" });
173
+ if (!canTransition(order, action)) {
174
+ throw new Error(`Cannot ${action} from ${order.status}`);
175
+ }
176
+ const map = { accept: "accepted", reject: "cancelled", start_preparing: "preparing", mark_ready: "ready", complete_pickup: "delivered", cancel: "cancelled", assign_rider: "assigned", confirm_handover: "delivered", confirm_cash: order.status, pay: order.status };
177
+ const toStatus = map[action] ?? order.status;
178
+ if (action === "confirm_cash" || action === "pay") {
179
+ await updateOrderPayment(db, businessId, orderId, { is_paid: true, paid_at: new Date().toISOString() });
180
+ }
181
+ else if (toStatus !== order.status) {
182
+ await updateOrderStatus(db, businessId, orderId, toStatus);
183
+ }
184
+ try {
185
+ const now = new Date().toISOString();
186
+ const jobId = generateUUID();
187
+ await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);`, jobId, businessId, `order:${action}`, orderId, JSON.stringify({ action, orderId }), now, now);
188
+ syncLogger.debug("DomainOrders", "transition sync_jobs enqueued", { businessId, orderId, action, jobId });
189
+ void drainOne(businessId, orderId).catch((e) => syncLogger.debug("DomainOrders", "drainOne after transition caught", { err: String(e) }));
190
+ }
191
+ catch (e) {
192
+ syncLogger.debug("DomainOrders", "transition enqueue fail", { err: String(e) });
193
+ }
194
+ const updated = await getOrderById(db, businessId, orderId);
195
+ syncLogger.debug("DomainOrders", "transitionLocalOrder done", { businessId, orderId, action, from: order.status, to: updated?.status });
196
+ return updated ?? order;
197
+ }
198
+ export async function listLocalOrders(businessId, filters, page = 1, pageSize = 20) {
199
+ await ensureOrdersBackfilledFromSnapshots(businessId);
200
+ const db = await openSyncDatabase();
201
+ const res = await listOrders(db, businessId, filters, page, pageSize);
202
+ if (res.total === 0) {
203
+ // blank-screen: snapshot fallback already tried — if still empty and online, fetch from cloud now so listing populates without manual refresh
204
+ if (isOnline() && getAuthToken()) {
205
+ try {
206
+ await pullRemoteOrders(businessId);
207
+ }
208
+ catch { }
209
+ const afterPull = await listOrders(db, businessId, filters, page, pageSize);
210
+ if (afterPull.total > 0)
211
+ return afterPull;
212
+ }
213
+ // filtered miss — still try backfill re-read
214
+ if (!filters || Object.keys(filters).length === 0) {
215
+ await ensureOrdersBackfilledFromSnapshots(businessId);
216
+ if (backfilled.has(businessId)) {
217
+ const retry = await listOrders(db, businessId, filters, page, pageSize);
218
+ if (retry.total > 0)
219
+ return retry;
220
+ }
221
+ }
222
+ }
223
+ return res;
224
+ }
225
+ export async function getLocalOrder(businessId, idOrNumber) {
226
+ await ensureOrdersBackfilledFromSnapshots(businessId);
227
+ const db = await openSyncDatabase();
228
+ const found = await getOrderById(db, businessId, idOrNumber);
229
+ if (found)
230
+ return found;
231
+ // fallback to snapshots for direct link before cloud pull
232
+ try {
233
+ const snaps = await getEntitySnapshotsByType(db, "seller_orders", businessId);
234
+ for (const s of snaps) {
235
+ const hit = s.payload?.data?.find((o) => o.id === idOrNumber || o.order_number === idOrNumber);
236
+ if (hit)
237
+ return hit;
238
+ }
239
+ }
240
+ catch { }
241
+ return null;
242
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Payments — is_paid independent of status (§19).
3
+ * Payment events patch only is_paid/paid_at/payment.is_paid.
4
+ */
5
+ export { orderRepository } from "../../repositories/order.repository";
6
+ export type { SellerOrder } from "../../types/api/seller.api";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Payments — is_paid independent of status (§19).
3
+ * Payment events patch only is_paid/paid_at/payment.is_paid.
4
+ */
5
+ export { orderRepository } from "../../repositories/order.repository";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Reference data — per-key last-HLC (§11), Food-specific.
3
+ */
4
+ export * from "../../reference/operations";
5
+ export * from "../../reference/service";
6
+ export * from "../../reference/api";
7
+ export * from "../../reference/events";
8
+ export * from "../../reference/capabilities";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Reference data — per-key last-HLC (§11), Food-specific.
3
+ */
4
+ export * from "../../reference/operations";
5
+ export * from "../../reference/service";
6
+ export * from "../../reference/api";
7
+ export * from "../../reference/events";
8
+ export * from "../../reference/capabilities";
@@ -0,0 +1 @@
1
+ export declare function resetHarness(businessId?: string): Promise<string>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * E2E harness — real SQLite (memory), real SyncEngine, real command queue, real event journal.
3
+ * Mock cloud transport in-memory to simulate backend idempotency and Phoenix business:{id}.
4
+ */
5
+ import { createMemoryDriver } from "../test-helpers/memory-driver";
6
+ import { setSqliteDriver } from "../adapters/sqlite";
7
+ import { closeSyncDatabase, openSyncDatabase } from "../db/connection";
8
+ import { setActiveBusiness } from "../adapters/business";
9
+ import { setConnectivityState } from "../adapters/connectivity";
10
+ import { setHttpClient } from "../adapters/http";
11
+ import { setItemSync } from "../adapters/kv";
12
+ import { hydratePendingOrders, applyPendingOrderEvent, applyOrderStateChangeEvent } from "../sync/pending-orders.service";
13
+ import { usePendingOrdersStore } from "../adapters/pending-orders";
14
+ import { syncEngine } from "../sync/engine";
15
+ import { setAuthProvider } from "../adapters/auth";
16
+ import { seedOpenHours } from "../test-helpers/seed-hours";
17
+ /** NOTE: the engine mock transport is retired with the single create path.
18
+ * E2E tests mock the HTTP client directly (see core.e2e.test.ts) because
19
+ * creation flows through createLocalOrder → sync_jobs → REST. */
20
+ let subscribed = false;
21
+ export async function resetHarness(businessId = "biz-e2e") {
22
+ const driver = createMemoryDriver();
23
+ setSqliteDriver(driver);
24
+ try {
25
+ await closeSyncDatabase();
26
+ }
27
+ catch { }
28
+ await openSyncDatabase();
29
+ setActiveBusiness({ id: businessId });
30
+ setConnectivityState({ isOnline: true, isCloudReachable: "reachable" });
31
+ setItemSync(`teinc_device_number:${businessId}`, "07");
32
+ // ensure restTransport isAvailable (needs http client)
33
+ setHttpClient({
34
+ get: async () => ({ data: [] }),
35
+ post: async () => ({ data: {} }),
36
+ put: async () => ({ data: {} }),
37
+ patch: async () => ({ data: {} }),
38
+ delete: async () => ({ data: {} }),
39
+ });
40
+ // reset callout
41
+ setItemSync("teinc_callout_seq", 0);
42
+ setItemSync("teinc_callout_seq:biz-e2e", 0);
43
+ setItemSync("teinc_callout_seq:biz-A", 0);
44
+ setItemSync("teinc_callout_seq:biz-B", 0);
45
+ try {
46
+ setAuthProvider(() => "token-e2e");
47
+ }
48
+ catch { }
49
+ // Creation is fail-closed on operating hours — seed 24/7 so e2e creates pass.
50
+ await seedOpenHours(businessId).catch(() => { });
51
+ usePendingOrdersStore.setState({ orders: [], hydrated: false });
52
+ if (!subscribed) {
53
+ subscribed = true;
54
+ syncEngine.subscribe((evt) => {
55
+ void applyPendingOrderEvent(evt, "local");
56
+ void applyOrderStateChangeEvent(evt, "local");
57
+ });
58
+ }
59
+ await hydratePendingOrders().catch(() => { });
60
+ return businessId;
61
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Seller analytics hooks — single source for both apps.
3
+ * Offline-first: local SQLite aggregates serve instantly; the network
4
+ * refreshes silently when online.
5
+ */
6
+ export declare const SELLER_ANALYTICS_KEYS: {
7
+ all: readonly ["seller-analytics"];
8
+ analytics: (businessId?: string, period?: string) => readonly ["seller-analytics", string | undefined, string | undefined];
9
+ };
10
+ export declare const REVENUE_KEYS: {
11
+ all: readonly ["revenue-summary"];
12
+ summary: (businessId?: string, period?: string) => readonly ["revenue-summary", string | undefined, string | undefined];
13
+ };
14
+ export declare function useSellerAnalyticsQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
15
+ data?: {
16
+ period?: string;
17
+ revenue_by_day?: unknown[];
18
+ };
19
+ }, Error>;
20
+ export declare function useRevenueSummaryQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
21
+ data?: {
22
+ period?: string;
23
+ by_day?: unknown[];
24
+ };
25
+ }, Error>;