@teincfood/core 0.10.7 → 0.10.8

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.
@@ -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>
@@ -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.8",
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",