@teincfood/core 0.10.7 → 0.10.9

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.
@@ -14,6 +14,13 @@ export declare const usePendingOrdersStore: {
14
14
  hydrated: boolean;
15
15
  orders: SellerOrder[];
16
16
  addOrder: (order: SellerOrder) => void;
17
+ /**
18
+ * Replace an existing pending order in place. Used for queued
19
+ * transitions/payments so the optimistic overlay reflects the new state
20
+ * immediately. Unknown orders are ignored (a transition must never
21
+ * resurrect an order that was already reconciled).
22
+ */
23
+ updateOrder: (order: SellerOrder) => void;
17
24
  removeByOrderNumber: (orderNumber: string) => void;
18
25
  removeByOrderId: (orderId: string) => void;
19
26
  setHydrated: (v: boolean) => void;
@@ -30,6 +30,25 @@ export const usePendingOrdersStore = {
30
30
  notify();
31
31
  }
32
32
  },
33
+ /**
34
+ * Replace an existing pending order in place. Used for queued
35
+ * transitions/payments so the optimistic overlay reflects the new state
36
+ * immediately. Unknown orders are ignored (a transition must never
37
+ * resurrect an order that was already reconciled).
38
+ */
39
+ updateOrder: (order) => {
40
+ const index = orders.findIndex((o) => o.id === order.id || o.order_number === order.order_number);
41
+ if (index === -1)
42
+ return;
43
+ const current = orders[index];
44
+ if (current.status === order.status &&
45
+ current.is_paid === order.is_paid &&
46
+ current.paid_at === order.paid_at) {
47
+ return;
48
+ }
49
+ orders = orders.map((o, i) => (i === index ? order : o));
50
+ notify();
51
+ },
33
52
  removeByOrderNumber: (orderNumber) => {
34
53
  const next = orders.filter((o) => o.order_number !== orderNumber);
35
54
  if (next.length !== orders.length) {
@@ -112,6 +112,15 @@ export declare function upsertServerOrder(db: SQLiteDatabase, order: import("../
112
112
  preserveStatus?: boolean;
113
113
  preservePayment?: boolean;
114
114
  }): Promise<void>;
115
+ /**
116
+ * Recent orders plus their local HLC, for LAN catch-up.
117
+ *
118
+ * The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
119
+ * replaying an older snapshot can never overwrite a newer local status.
120
+ */
121
+ export declare function listOrdersWithHlc(db: SQLiteDatabase, businessId: string, limit?: number): Promise<Array<import("../types/api/seller.api").SellerOrder & {
122
+ hlc?: string;
123
+ }>>;
115
124
  export declare function listOrders(db: SQLiteDatabase, businessId: string, filters?: {
116
125
  status?: string;
117
126
  q?: string;
@@ -376,6 +376,19 @@ export async function upsertServerOrder(db, order, opts = {}) {
376
376
  ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
377
377
  notifyDbChange(order.business_id, "orders");
378
378
  }
379
+ /**
380
+ * Recent orders plus their local HLC, for LAN catch-up.
381
+ *
382
+ * The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
383
+ * replaying an older snapshot can never overwrite a newer local status.
384
+ */
385
+ export async function listOrdersWithHlc(db, businessId, limit = 200) {
386
+ const rows = await db.getAllAsync(`SELECT * FROM orders WHERE business_id = ? AND deleted_at IS NULL ORDER BY inserted_at DESC LIMIT ?;`, businessId, limit);
387
+ return rows.map((row) => ({
388
+ ...orderRowToSellerOrder(row),
389
+ hlc: row.hlc ?? row.updated_at ?? row.inserted_at,
390
+ }));
391
+ }
379
392
  export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
380
393
  const where = ["business_id = ?"];
381
394
  const args = [businessId];
@@ -22,7 +22,7 @@ export interface SellerOrderFilters {
22
22
  date_to?: string;
23
23
  }
24
24
  export declare function useSellerOrdersQuery(enabled?: boolean, filters?: SellerOrderFilters): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
25
- export declare function useSellerOrderQuery(orderId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
25
+ export declare function useSellerOrderQuery(orderId: string, enabled?: boolean, placeholder?: SellerOrder | null): import("@tanstack/react-query").UseQueryResult<{
26
26
  data: SellerOrder;
27
27
  }, Error>;
28
28
  export declare function useCreatePOSOrderMutation(): import("@tanstack/react-query").UseMutationResult<SellerOrder, Error, POSOrderCreateRequest, unknown>;
@@ -81,7 +81,7 @@ export function useSellerOrdersQuery(enabled = true, filters) {
81
81
  select: (data) => data.pages.flatMap((p) => p.data),
82
82
  });
83
83
  }
84
- export function useSellerOrderQuery(orderId, enabled = true) {
84
+ export function useSellerOrderQuery(orderId, enabled = true, placeholder) {
85
85
  const bid = useBusinessId();
86
86
  return useQuery({
87
87
  queryKey: SELLER_KEYS.order(orderId, bid),
@@ -94,6 +94,12 @@ export function useSellerOrderQuery(orderId, enabled = true) {
94
94
  return { data: order };
95
95
  },
96
96
  enabled: enabled && !!orderId && !!bid,
97
+ // The order list already has the full row. Seed the dialog with it so
98
+ // opening details is instant; the DB read still runs and replaces it.
99
+ placeholderData: placeholder ? { data: placeholder } : undefined,
100
+ // Local SQLite reads cannot recover from a transient network error; a
101
+ // missing row should surface immediately instead of retrying for seconds.
102
+ retry: false,
97
103
  staleTime: 5 * 1000,
98
104
  });
99
105
  }
@@ -24,6 +24,18 @@ declare class LocalNodeServiceCore {
24
24
  init(deps: LocalNodeDeps): void;
25
25
  private requestReferenceFromPeers;
26
26
  private requestAssetFromPeers;
27
+ /**
28
+ * Catch a newly connected peer up on orders created while the mesh was not
29
+ * connected (e.g. a sale rung up before Local Node was enabled).
30
+ *
31
+ * Live order events only cover changes that happen *after* the socket is
32
+ * up. Without this handshake a pre-existing offline sale stayed invisible
33
+ * on the other terminal forever. Both sides call this on connect; the
34
+ * cooldown dedupes the inbound/outbound callbacks for the same peer.
35
+ */
36
+ private orderCatchupAt;
37
+ private static readonly ORDER_CATCHUP_COOLDOWN_MS;
38
+ private publishOrderCatchup;
27
39
  private getDeps;
28
40
  private getDeviceId;
29
41
  private runLifecycle;
@@ -12,7 +12,9 @@ import { REFERENCE_CHANGED_EVENT_TYPE } from "../reference/events";
12
12
  import { applyReferenceChangeEvent } from "../reference/events";
13
13
  import { referenceSyncService } from "../reference/service";
14
14
  import { openSyncDatabase } from "../db/connection";
15
- import { insertOrder } from "../db/operations";
15
+ import { insertOrder, listOrdersWithHlc } from "../db/operations";
16
+ import { getPendingOrdersSnapshot } from "../adapters/pending-orders";
17
+ import { generateEventId } from "../utils/uuid";
16
18
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
17
19
  import { getConnectivityState } from "../adapters/connectivity";
18
20
  import { deriveLanSecret as deriveSecretCore } from "./security";
@@ -46,6 +48,16 @@ class LocalNodeServiceCore {
46
48
  this.published = false;
47
49
  /** Whether discovery scanning was successfully started. */
48
50
  this.discovering = false;
51
+ /**
52
+ * Catch a newly connected peer up on orders created while the mesh was not
53
+ * connected (e.g. a sale rung up before Local Node was enabled).
54
+ *
55
+ * Live order events only cover changes that happen *after* the socket is
56
+ * up. Without this handshake a pre-existing offline sale stayed invisible
57
+ * on the other terminal forever. Both sides call this on connect; the
58
+ * cooldown dedupes the inbound/outbound callbacks for the same peer.
59
+ */
60
+ this.orderCatchupAt = new Map();
49
61
  this.storeUnsub = null;
50
62
  this.dialInFlight = new Set();
51
63
  this.dialTimers = new Map();
@@ -89,6 +101,63 @@ class LocalNodeServiceCore {
89
101
  }
90
102
  return null;
91
103
  }
104
+ async publishOrderCatchup(peerDeviceId) {
105
+ if (!this.businessId)
106
+ return;
107
+ const now = Date.now();
108
+ const last = this.orderCatchupAt.get(peerDeviceId) ?? 0;
109
+ if (now - last < LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS)
110
+ return;
111
+ this.orderCatchupAt.set(peerDeviceId, now);
112
+ try {
113
+ const db = await openSyncDatabase();
114
+ const rows = await listOrdersWithHlc(db, this.businessId, 200);
115
+ const byId = new Map();
116
+ for (const order of rows)
117
+ byId.set(order.id, order);
118
+ // Queued offline creations have no `orders` row yet; they live in the
119
+ // pending overlay. Include them so a pre-linkup sale is shared too.
120
+ for (const order of getPendingOrdersSnapshot().orders) {
121
+ if (order.business_id === this.businessId)
122
+ byId.set(order.id, order);
123
+ }
124
+ const peer = this.peers.get(peerDeviceId);
125
+ const canTarget = !!peer?.isConnected() && typeof peer.publishEvent === "function";
126
+ for (const order of byId.values()) {
127
+ if (!order?.id || !order.order_number)
128
+ continue;
129
+ const event = {
130
+ event_id: generateEventId(),
131
+ event_type: "order:sync",
132
+ event_version: "1",
133
+ entity_id: order.id,
134
+ user_id: "",
135
+ device_id: this.cachedDeviceId ?? "",
136
+ payload: order,
137
+ timestamp: order.updated_at ?? order.inserted_at ?? new Date().toISOString(),
138
+ };
139
+ if (canTarget) {
140
+ await peer.publishEvent(event);
141
+ }
142
+ else {
143
+ this.server?.broadcast(event);
144
+ }
145
+ }
146
+ syncLogger.info("LocalNodeService", "Order catch-up published", {
147
+ peer: peerDeviceId,
148
+ orders: byId.size,
149
+ targeted: canTarget,
150
+ });
151
+ }
152
+ catch (error) {
153
+ syncLogger.warn("LocalNodeService", "Order catch-up failed", {
154
+ peer: peerDeviceId,
155
+ error: String(error),
156
+ });
157
+ // Allow a later reconnect to retry after a transient DB/socket failure.
158
+ this.orderCatchupAt.delete(peerDeviceId);
159
+ }
160
+ }
92
161
  getDeps() {
93
162
  if (!this.deps)
94
163
  throw new Error("LocalNodeService not initialised: call initLocalNodeService(deps) first");
@@ -200,6 +269,7 @@ class LocalNodeServiceCore {
200
269
  catch { }
201
270
  this.peers.clear();
202
271
  this.peerInfos.clear();
272
+ this.orderCatchupAt.clear();
203
273
  if (this.server) {
204
274
  await withTimeout(this.server.stop(), LIFECYCLE_TIMEOUT_MS).catch((e) => syncLogger.warn("LocalNodeService", "Server stop timed out", { error: String(e) }));
205
275
  this.server = null;
@@ -359,6 +429,9 @@ class LocalNodeServiceCore {
359
429
  onClientConnected: (info) => {
360
430
  localNodeStore.getState().addConnectedClient({ deviceId: info.deviceId, deviceName: info.deviceName, host: info.host, connectedAt: new Date().toISOString() });
361
431
  syncLogger.info("LocalNodeService", "Client connected", { deviceId: info.deviceId, connected_clients: localNodeStore.getState().connectedClients.length });
432
+ // Inbound connect: push this node's pre-existing orders to the new
433
+ // peer (covers mDNS asymmetry where only one side dials).
434
+ void this.publishOrderCatchup(info.deviceId);
362
435
  },
363
436
  onClientDisconnected: (deviceId) => {
364
437
  localNodeStore.getState().removeConnectedClient(deviceId);
@@ -602,6 +675,8 @@ class LocalNodeServiceCore {
602
675
  syncLogger.info("LocalNodeService", "Connected to peer", { deviceId: node.deviceId });
603
676
  this.peerInfos.set(node.deviceId, { deviceId: node.deviceId, host: node.host, port: node.port });
604
677
  localNodeStore.getState().addConnectedClient({ deviceId: node.deviceId, deviceName: node.deviceName, host: node.host, connectedAt: new Date().toISOString() });
678
+ // Outbound connect: push orders created before this socket existed.
679
+ void this.publishOrderCatchup(node.deviceId);
605
680
  // A terminal may have missed `reference_data.changed` while it was
606
681
  // disconnected. Compare versions with the peer on reconnect so menu,
607
682
  // members, hours and tax changes converge even on a dark LAN.
@@ -817,6 +892,7 @@ class LocalNodeServiceCore {
817
892
  }
818
893
  getState() { return { isLocalNode: this.isLocalNode(), isConnectedToNode: this.isConnectedToNode(), electedNode: this.getElectedNode(), client: this.getClient() }; }
819
894
  }
895
+ LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS = 10000;
820
896
  LocalNodeServiceCore.MISMATCH_BACKOFF_MS = 60000;
821
897
  export const localNodeService = new LocalNodeServiceCore();
822
898
  export function initLocalNodeService(deps) { localNodeService.init(deps); return localNodeService; }
@@ -100,7 +100,11 @@ function nameBlockEscpos(name) {
100
100
  if (text.length <= 16)
101
101
  return [GS_SIZE_DOUBLE_W, text, LF, GS_SIZE_NORMAL];
102
102
  if (text.length <= 64) {
103
- return [ESC_BOLD_ON, ...wrapText(text, 32).flatMap((l) => [escposText(l), LF]), ESC_BOLD_OFF];
103
+ return [
104
+ ESC_BOLD_ON,
105
+ ...wrapText(text, 32).flatMap((l) => [escposText(l), LF]),
106
+ ESC_BOLD_OFF,
107
+ ];
104
108
  }
105
109
  return wrapText(text, 32).flatMap((l) => [escposText(l), LF]);
106
110
  }
@@ -114,11 +118,16 @@ function nameSizeClass(name) {
114
118
  }
115
119
  function currencySymbol(currency) {
116
120
  switch (currency) {
117
- case "GHS": return "₵";
118
- case "USD": return "$";
119
- case "EUR": return "€";
120
- case "GBP": return "£";
121
- default: return currency ? currency + " " : "";
121
+ case "GHS":
122
+ return "₵";
123
+ case "USD":
124
+ return "$";
125
+ case "EUR":
126
+ return "€";
127
+ case "GBP":
128
+ return "£";
129
+ default:
130
+ return currency ? currency + " " : "";
122
131
  }
123
132
  }
124
133
  function formatMoney(amountMinor, order) {
@@ -134,7 +143,20 @@ function formatDatetime(iso) {
134
143
  const d = new Date(iso);
135
144
  // Backend: Calendar.strftime(dt, "%d %b %Y %H:%M") e.g. "03 Sep 2026 14:05"
136
145
  const day = String(d.getDate()).padStart(2, "0");
137
- const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
146
+ const months = [
147
+ "Jan",
148
+ "Feb",
149
+ "Mar",
150
+ "Apr",
151
+ "May",
152
+ "Jun",
153
+ "Jul",
154
+ "Aug",
155
+ "Sep",
156
+ "Oct",
157
+ "Nov",
158
+ "Dec",
159
+ ];
138
160
  const mon = months[d.getMonth()];
139
161
  const year = d.getFullYear();
140
162
  const hh = String(d.getHours()).padStart(2, "0");
@@ -147,7 +169,8 @@ function formatDatetime(iso) {
147
169
  }
148
170
  export function calloutNumber(order) {
149
171
  // The backend assigns a real callout_number — always prefer it.
150
- const direct = order.callout_number;
172
+ const direct = order
173
+ .callout_number;
151
174
  if (typeof direct === "number" && Number.isFinite(direct))
152
175
  return String(Math.abs(direct));
153
176
  const num = order.order_number || "";
@@ -211,7 +234,11 @@ function paymentLineText(order) {
211
234
  function escHtml(s) {
212
235
  if (!s)
213
236
  return "";
214
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
237
+ return s
238
+ .replace(/&/g, "&amp;")
239
+ .replace(/</g, "&lt;")
240
+ .replace(/>/g, "&gt;")
241
+ .replace(/"/g, "&quot;");
215
242
  }
216
243
  function businessAddress(business) {
217
244
  const b = business;
@@ -238,7 +265,9 @@ function businessLogoUrl(order) {
238
265
  const b = order.business;
239
266
  if (!b)
240
267
  return null;
241
- return (b.logo_url ?? b.logoUrl ?? null);
268
+ return (b.logo_url ??
269
+ b.logoUrl ??
270
+ null);
242
271
  }
243
272
  /** Fetch remote URL (or tfimg://) and return data URI, or null on failure. Uses Web fetch; works for http(s) and for cached tfimg via Rust protocol. */
244
273
  async function fetchAsDataUri(url) {
@@ -291,7 +320,8 @@ function binaryFromBase64(b64) {
291
320
  * reference. Null when none — fully offline, no network.
292
321
  */
293
322
  export async function resolveLogoRaster(order) {
294
- const direct = order.business?.logo_escpos;
323
+ const direct = order.business
324
+ ?.logo_escpos;
295
325
  if (typeof direct === "string" && direct.length > 0)
296
326
  return direct;
297
327
  try {
@@ -308,30 +338,50 @@ export async function resolveLogoRaster(order) {
308
338
  }
309
339
  }
310
340
  // ── ESC/POS ────────────────────────────────────────────────────────────
311
- function doubleLineEscpos() { return "═".repeat(LINE_WIDTH) + LF; }
312
- function singleLineEscpos() { return "─".repeat(LINE_WIDTH); }
341
+ function doubleLineEscpos() {
342
+ return "═".repeat(LINE_WIDTH) + LF;
343
+ }
344
+ function singleLineEscpos() {
345
+ return "─".repeat(LINE_WIDTH);
346
+ }
313
347
  function customerLineEscpos(order) {
314
348
  if (order.customer_name)
315
349
  return `Customer: ${order.customer_name}${LF}`;
316
350
  if (order.customer_phone)
317
351
  return `Phone: ${order.customer_phone}${LF}`;
318
- if (order.user?.full_name)
352
+ // POS orders link the teller, not a customer account. Never print staff as
353
+ // the customer; non-POS orders may fall back to the ordering user.
354
+ if (!order.is_pos && order.user?.full_name)
319
355
  return `Customer: ${order.user.full_name}${LF}`;
320
356
  return null;
321
357
  }
322
- function orderTypeEscpos(order) { return orderTypeText(order); }
323
- function paymentLineEscpos(order) { return paymentLineText(order); }
358
+ function tellerLineEscpos(order) {
359
+ const teller = order.teller?.full_name ?? (order.is_pos ? order.user?.full_name : null);
360
+ return order.is_pos && teller ? `Served by: ${teller}${LF}` : null;
361
+ }
362
+ function orderTypeEscpos(order) {
363
+ return orderTypeText(order);
364
+ }
365
+ function paymentLineEscpos(order) {
366
+ return paymentLineText(order);
367
+ }
324
368
  export function generateEscpos(order, logoRasterB64) {
325
369
  const business = order.business;
326
370
  const items = order.items || [];
327
- const taxLines = (order.order_tax_lines ?? order.tax_lines ?? []);
371
+ const taxLines = (order.order_tax_lines ??
372
+ order.tax_lines ??
373
+ []);
328
374
  const parts = [];
329
- const push = (s) => { if (s != null)
330
- parts.push(s); };
375
+ const push = (s) => {
376
+ if (s != null)
377
+ parts.push(s);
378
+ };
331
379
  // Sanitized text push: every human-readable segment goes through escposText
332
380
  // (control codes and barcode data use raw push).
333
- const pushT = (s) => { if (s != null)
334
- parts.push(escposText(s)); };
381
+ const pushT = (s) => {
382
+ if (s != null)
383
+ parts.push(escposText(s));
384
+ };
335
385
  // Header — implements docs/receipt-template.md §1
336
386
  // NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
337
387
  // a raster pipeline (GS v 0). HTML receipts embed the logo instead.
@@ -340,7 +390,9 @@ export function generateEscpos(order, logoRasterB64) {
340
390
  try {
341
391
  push(binaryFromBase64(logoRasterB64));
342
392
  }
343
- catch { /* logo is decorative — never fail the receipt */ }
393
+ catch {
394
+ /* logo is decorative — never fail the receipt */
395
+ }
344
396
  }
345
397
  push(ESC_ALIGN_CENTER);
346
398
  for (const part of nameBlockEscpos(business?.name || "Teinc Food"))
@@ -369,6 +421,9 @@ export function generateEscpos(order, logoRasterB64) {
369
421
  const custLine = customerLineEscpos(order);
370
422
  if (custLine)
371
423
  pushT(custLine);
424
+ const tellerLine = tellerLineEscpos(order);
425
+ if (tellerLine)
426
+ pushT(tellerLine);
372
427
  push(LF);
373
428
  // Barcode — CODE128 GS k 73, sized down (default height is huge).
374
429
  push(ESC_ALIGN_CENTER);
@@ -417,7 +472,9 @@ export function generateEscpos(order, logoRasterB64) {
417
472
  pushT(`${padR("Delivery Fee:", 20)} ${padL(escposText(formatMoney(order.delivery_fee_minor, order)), 11)}${LF}`);
418
473
  }
419
474
  for (const tax of taxLines) {
420
- const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
475
+ const rateStr = tax.rate_basis_points
476
+ ? `(${Math.floor(tax.rate_basis_points / 100)}%)`
477
+ : "";
421
478
  const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
422
479
  pushT(`${padR(label, 20)} ${padL(escposText(formatMoney(tax.tax_amount_minor, order)), 11)}${LF}`);
423
480
  }
@@ -499,15 +556,20 @@ export async function generateHtmlAsync(order, opts) {
499
556
  export function generateHtml(order, logoDataUri) {
500
557
  const business = order.business;
501
558
  const items = order.items || [];
502
- const taxLines = ((order.order_tax_lines) ?? order.tax_lines ?? []);
559
+ const taxLines = (order.order_tax_lines ??
560
+ order.tax_lines ??
561
+ []);
503
562
  const callout = calloutNumber(order);
504
- const logoSrc = logoDataUri || business?.logo_url || "";
563
+ const logoSrc = logoDataUri ||
564
+ business?.logo_url ||
565
+ "";
505
566
  const hasLogo = !!logoSrc;
506
567
  const businessName = escHtml(business?.name || "Teinc Food");
507
568
  const company = getCompanyInfo();
508
569
  const addr = businessAddress(business);
509
570
  const bPhone = business?.phone;
510
- const itemRows = items.map((item) => {
571
+ const itemRows = items
572
+ .map((item) => {
511
573
  const qty = item.quantity || 1;
512
574
  // unit_price is already minor? backend format_money expects minor
513
575
  const price = formatMoney((item.unit_price ?? 0) * qty, order);
@@ -517,17 +579,24 @@ export function generateHtml(order, logoDataUri) {
517
579
  ? `<tr class="variant-row"><td class="item-name" colspan="2">Size: ${escHtml(vName)}</td><td class="item-price"></td></tr>`
518
580
  : "";
519
581
  const addons = item.order_item_addons ?? item.addons ?? [];
520
- const addonRows = addons.map((addon) => {
582
+ const addonRows = addons
583
+ .map((addon) => {
521
584
  const aName = escHtml(addonName(addon));
522
585
  const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
523
586
  return `<tr class="addon-row"><td class="item-name">+ ${aName}</td><td class="item-qty"></td><td class="addon-price">${formatMoney(aPriceMinor, order)}</td></tr>`;
524
- }).join("\n");
587
+ })
588
+ .join("\n");
525
589
  return [main, variantRow, addonRows].filter(Boolean).join("\n");
526
- }).join("\n");
527
- const taxRowsHtml = taxLines.map((tax) => {
528
- const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
590
+ })
591
+ .join("\n");
592
+ const taxRowsHtml = taxLines
593
+ .map((tax) => {
594
+ const rateStr = tax.rate_basis_points
595
+ ? `(${Math.floor(tax.rate_basis_points / 100)}%)`
596
+ : "";
529
597
  return `<tr><td class="label">${escHtml(tax.name || "")} ${escHtml(rateStr)}</td><td class="amount">${formatMoney(tax.tax_amount_minor, order)}</td></tr>`;
530
- }).join("\n");
598
+ })
599
+ .join("\n");
531
600
  // Exact style from receipt.ex to_html/2
532
601
  return `<!DOCTYPE html>
533
602
  <html>
@@ -572,7 +641,14 @@ export function generateHtml(order, logoDataUri) {
572
641
  <strong>Order:</strong> ${escHtml(order.order_number)}<br>
573
642
  <strong>Date:</strong> ${escHtml(formatDatetime(order.inserted_at))}<br>
574
643
  <strong>Type:</strong> ${escHtml(orderTypeText(order))}<br>
575
- ${order.customer_name ? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>` : order.user?.full_name ? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>` : ""}
644
+ ${order.customer_name
645
+ ? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>`
646
+ : !order.is_pos && order.user?.full_name
647
+ ? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>`
648
+ : ""}
649
+ ${order.is_pos && (order.teller?.full_name || order.user?.full_name)
650
+ ? `<strong>Served by:</strong> ${escHtml(order.teller?.full_name || order.user.full_name)}<br>`
651
+ : ""}
576
652
  </div>
577
653
  <div class="callout">
578
654
  <div class="number">${escHtml(callout)}</div>
@@ -18,6 +18,7 @@ import { assertBusinessOpenForCreate } from "../utils/business-hours";
18
18
  import { extractApiErrorCode } from "../utils/error";
19
19
  import { generateEventId } from "../utils/uuid";
20
20
  import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
21
+ import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
21
22
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
22
23
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
23
24
  import { canBusinessSync } from "./sync-guard";
@@ -53,7 +54,7 @@ const PAYMENT_QUEUED_PAYLOAD = {
53
54
  export function getQueuedStatus(commandType) {
54
55
  return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
55
56
  }
56
- function buildOptimisticEvent(command) {
57
+ function buildOptimisticEvent(command, flowMode = DEFAULT_ORDER_FLOW_MODE) {
57
58
  const payload = command.payload;
58
59
  if (command.command_type === "order:create") {
59
60
  return {
@@ -63,7 +64,7 @@ function buildOptimisticEvent(command) {
63
64
  entity_id: command.entity_id ?? command.command_id,
64
65
  user_id: command.user_id,
65
66
  device_id: command.device_id,
66
- payload: buildOptimisticOrder(command, payload),
67
+ payload: buildOptimisticOrder(command, payload, flowMode),
67
68
  timestamp: new Date().toISOString(),
68
69
  };
69
70
  }
@@ -77,6 +78,7 @@ function buildOptimisticEvent(command) {
77
78
  device_id: command.device_id,
78
79
  payload: {
79
80
  ...payload,
81
+ business_id: command.business_id,
80
82
  command_id: command.command_id,
81
83
  is_paid: true,
82
84
  paid_at: new Date().toISOString(),
@@ -95,6 +97,9 @@ function buildOptimisticEvent(command) {
95
97
  device_id: command.device_id,
96
98
  payload: {
97
99
  ...payload,
100
+ // Required by the offline read-model patch so queued transitions update
101
+ // the canonical orders table (not just seller_orders snapshots).
102
+ business_id: command.business_id,
98
103
  command_id: command.command_id,
99
104
  status: "pending",
100
105
  // Used by offline read-model patching so the list reflects the queued transition.
@@ -106,7 +111,7 @@ function buildOptimisticEvent(command) {
106
111
  timestamp: new Date().toISOString(),
107
112
  };
108
113
  }
109
- function buildOptimisticOrder(command, payload) {
114
+ function buildOptimisticOrder(command, payload, flowMode) {
110
115
  const snapshots = buildItemSnapshots((payload.item_snapshots ?? payload.items ?? []));
111
116
  const totalMinor = payload.total_minor ?? 0;
112
117
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
@@ -114,6 +119,8 @@ function buildOptimisticOrder(command, payload) {
114
119
  const taxLines = payload.tax_lines ?? [];
115
120
  const currencyCode = payload.currency_code ?? "GHS";
116
121
  const now = new Date().toISOString();
122
+ const method = payload.pos_payment_method ?? "cash";
123
+ const isCash = method === "cash";
117
124
  const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
118
125
  return {
119
126
  id: command.entity_id ?? command.command_id,
@@ -123,20 +130,23 @@ function buildOptimisticOrder(command, payload) {
123
130
  is_pos: true,
124
131
  customer_name: payload.customer_name ?? null,
125
132
  customer_phone: payload.customer_phone ?? null,
126
- pos_payment_method: payload.pos_payment_method ?? "cash",
127
- paid_at: null,
128
- status: "accepted",
133
+ pos_payment_method: method,
134
+ paid_at: isCash ? now : null,
135
+ // Must match createLocalOrder: direct-flow cash sales are born delivered,
136
+ // even when the command is parked in the offline outbox. Hardcoding
137
+ // "accepted" made a completed sale show up on the preparing board.
138
+ status: initialPosOrderStatus(method, flowMode),
129
139
  delivery_id: null,
130
140
  delivery_status: null,
131
- is_paid: payload.pos_payment_method === "cash",
141
+ is_paid: isCash,
132
142
  payment: {
133
- is_paid: payload.pos_payment_method === "cash",
143
+ is_paid: isCash,
134
144
  reference: null,
135
145
  amount_minor: totalMinor,
136
146
  currency: currencyCode,
137
- paid_at: payload.pos_payment_method === "cash" ? now : null,
147
+ paid_at: isCash ? now : null,
138
148
  },
139
- payment_mode: "pos_cash",
149
+ payment_mode: isCash ? "pos_cash" : "prepaid",
140
150
  total_minor: totalMinor,
141
151
  subtotal_minor: subtotalMinor,
142
152
  delivery_fee_minor: payload.delivery_fee_minor ?? 0,
@@ -268,7 +278,13 @@ class CommandQueueService {
268
278
  order_number: preparedCommand.payload.order_number ?? null,
269
279
  outbox_size: pending.length,
270
280
  });
271
- const event = buildOptimisticEvent(preparedCommand);
281
+ // Direct-flow cash sales are complete on creation. The optimistic order
282
+ // must use the same cached business setting as createLocalOrder, otherwise
283
+ // an offline direct sale appears as "accepted" and offers kitchen steps.
284
+ const flowMode = preparedCommand.command_type === "order:create"
285
+ ? await getBusinessOrderFlowMode(preparedCommand.business_id)
286
+ : DEFAULT_ORDER_FLOW_MODE;
287
+ const event = buildOptimisticEvent(preparedCommand, flowMode);
272
288
  syncLogger.info("CommandQueueService", "Optimistic event built", {
273
289
  event_type: event.event_type,
274
290
  entity_id: event.entity_id,
@@ -220,7 +220,7 @@ function patchPendingStatus(event) {
220
220
  if (!target)
221
221
  return;
222
222
  const patched = { ...target, status: toStatus };
223
- store.addOrder(patched);
223
+ store.updateOrder(patched);
224
224
  }
225
225
  catch { }
226
226
  }
@@ -244,7 +244,7 @@ function patchPendingPaid(event) {
244
244
  paid_at: paidAt,
245
245
  payment: { ...t.payment, is_paid: true, paid_at: paidAt },
246
246
  };
247
- store.addOrder(patched);
247
+ store.updateOrder(patched);
248
248
  }
249
249
  catch { }
250
250
  }
@@ -277,7 +277,7 @@ export async function applyPendingOrderEvent(event, origin = "local") {
277
277
  const store = usePendingOrdersStore.getState();
278
278
  const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
279
279
  if (target)
280
- store.addOrder({ ...target, status: toStatus });
280
+ store.updateOrder({ ...target, status: toStatus });
281
281
  }
282
282
  }
283
283
  catch { }
@@ -1,4 +1,4 @@
1
- import type { PaginatedResponse } from './pagination';
1
+ import type { PaginatedResponse } from "./pagination";
2
2
  export interface OrderUser {
3
3
  id: string;
4
4
  full_name: string;
@@ -6,6 +6,19 @@ export interface OrderUser {
6
6
  phone: string;
7
7
  avatar_url: string | null;
8
8
  }
9
+ /** Explicit customer identity. POS customers are typed names only, not users. */
10
+ export interface OrderCustomer {
11
+ user_id: string | null;
12
+ name: string | null;
13
+ phone: string | null;
14
+ avatar_url: string | null;
15
+ }
16
+ /** Business member who rang up a POS order. Distinct from the customer. */
17
+ export interface OrderTeller {
18
+ id: string;
19
+ full_name: string;
20
+ email?: string | null;
21
+ }
9
22
  export interface OrderPayment {
10
23
  is_paid: boolean;
11
24
  reference: string | null;
@@ -123,6 +136,9 @@ export interface SellerOrder {
123
136
  delivery_lat: number | null;
124
137
  delivery_lng: number | null;
125
138
  user: OrderUser | null;
139
+ /** Explicit customer vs staff split; older payloads may omit this. */
140
+ customer?: OrderCustomer | null;
141
+ teller?: OrderTeller | null;
126
142
  items: OrderItem[];
127
143
  inserted_at: string;
128
144
  updated_at?: string;
@@ -16,6 +16,14 @@ type OrderLike = Pick<SellerOrder, "customer_name"> & {
16
16
  avatar_url?: string;
17
17
  phone?: string;
18
18
  } | null;
19
+ customer?: {
20
+ name?: string | null;
21
+ phone?: string | null;
22
+ avatar_url?: string | null;
23
+ } | null;
24
+ teller?: {
25
+ full_name?: string | null;
26
+ } | null;
19
27
  customer_phone?: string | null;
20
28
  is_pos?: boolean;
21
29
  };
@@ -11,30 +11,39 @@
11
11
  */
12
12
  export function getCustomerDisplay(order) {
13
13
  if (order.is_pos) {
14
- const walkName = order.customer_name?.trim() ? order.customer_name.trim() : "Walk-in";
14
+ const typedName = order.customer?.name?.trim() || order.customer_name?.trim();
15
15
  return {
16
- name: walkName,
16
+ name: typedName || "Walk-in",
17
17
  avatarUrl: null,
18
- phone: order.customer_phone ?? null,
18
+ phone: order.customer?.phone ?? order.customer_phone ?? null,
19
19
  isWalkIn: true,
20
- tellerName: order.user?.full_name?.trim() ?? null,
20
+ tellerName: order.teller?.full_name?.trim() ??
21
+ order.user?.full_name?.trim() ??
22
+ null,
21
23
  };
22
24
  }
23
- const rawName = order.customer_name?.trim()
24
- ? order.customer_name.trim()
25
- : order.user?.full_name?.trim() ?? "";
25
+ const rawName = order.customer?.name?.trim() ||
26
+ order.customer_name?.trim() ||
27
+ order.user?.full_name?.trim() ||
28
+ "";
26
29
  if (rawName) {
27
30
  return {
28
31
  name: rawName,
29
- avatarUrl: order.user?.avatar_url ?? null,
30
- phone: order.customer_phone ?? order.user?.phone ?? null,
32
+ avatarUrl: order.customer?.avatar_url ?? order.user?.avatar_url ?? null,
33
+ phone: order.customer?.phone ??
34
+ order.customer_phone ??
35
+ order.user?.phone ??
36
+ null,
31
37
  isWalkIn: false,
32
38
  };
33
39
  }
34
40
  return {
35
41
  name: "Customer",
36
- avatarUrl: order.user?.avatar_url ?? null,
37
- phone: order.customer_phone ?? order.user?.phone ?? null,
42
+ avatarUrl: order.customer?.avatar_url ?? order.user?.avatar_url ?? null,
43
+ phone: order.customer?.phone ??
44
+ order.customer_phone ??
45
+ order.user?.phone ??
46
+ null,
38
47
  isWalkIn: false,
39
48
  };
40
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
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",