@teincfood/core 0.10.6 → 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.
package/dist/offline/store.js
CHANGED
|
@@ -204,8 +204,9 @@ export async function createOfflineMemberCredential(args) {
|
|
|
204
204
|
export const OFFLINE_PIN_MAX_ATTEMPTS = 5;
|
|
205
205
|
export async function getOfflinePinLockState(userId, businessId) {
|
|
206
206
|
const member = await getOfflineMember(userId, businessId);
|
|
207
|
-
const
|
|
208
|
-
const
|
|
207
|
+
const lockExpired = Boolean(member?.locked_until && new Date(member.locked_until).getTime() <= Date.now());
|
|
208
|
+
const failedAttempts = lockExpired ? 0 : member?.failed_attempts ?? 0;
|
|
209
|
+
const lockedUntil = lockExpired ? null : member?.locked_until ?? null;
|
|
209
210
|
const locked = Boolean(lockedUntil && new Date(lockedUntil).getTime() > Date.now());
|
|
210
211
|
return {
|
|
211
212
|
failedAttempts,
|
|
@@ -215,12 +216,16 @@ export async function getOfflinePinLockState(userId, businessId) {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
export async function verifyOfflineMemberPin(userId, pin, businessId) {
|
|
218
|
-
const
|
|
219
|
-
if (!
|
|
219
|
+
const stored = await getOfflineMember(userId, businessId);
|
|
220
|
+
if (!stored)
|
|
220
221
|
return false;
|
|
221
|
-
if (
|
|
222
|
+
if (stored.locked_until && new Date(stored.locked_until).getTime() > Date.now()) {
|
|
222
223
|
return false;
|
|
223
224
|
}
|
|
225
|
+
// An expired cooldown grants a fresh set of attempts.
|
|
226
|
+
const member = stored.locked_until && new Date(stored.locked_until).getTime() <= Date.now()
|
|
227
|
+
? { ...stored, failed_attempts: 0, locked_until: null }
|
|
228
|
+
: stored;
|
|
224
229
|
if (!/^\d{6}$/.test(pin)) {
|
|
225
230
|
await recordFailedPinAttempt(member);
|
|
226
231
|
return false;
|
package/dist/printer/receipt.js
CHANGED
|
@@ -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 [
|
|
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":
|
|
118
|
-
|
|
119
|
-
case "
|
|
120
|
-
|
|
121
|
-
|
|
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 = [
|
|
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
|
|
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
|
|
237
|
+
return s
|
|
238
|
+
.replace(/&/g, "&")
|
|
239
|
+
.replace(/</g, "<")
|
|
240
|
+
.replace(/>/g, ">")
|
|
241
|
+
.replace(/"/g, """);
|
|
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 ??
|
|
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
|
|
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() {
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
323
|
-
|
|
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 ??
|
|
371
|
+
const taxLines = (order.order_tax_lines ??
|
|
372
|
+
order.tax_lines ??
|
|
373
|
+
[]);
|
|
328
374
|
const parts = [];
|
|
329
|
-
const push = (s) => {
|
|
330
|
-
|
|
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) => {
|
|
334
|
-
|
|
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 {
|
|
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
|
|
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 = (
|
|
559
|
+
const taxLines = (order.order_tax_lines ??
|
|
560
|
+
order.tax_lines ??
|
|
561
|
+
[]);
|
|
503
562
|
const callout = calloutNumber(order);
|
|
504
|
-
const logoSrc = logoDataUri ||
|
|
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
|
|
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
|
|
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
|
-
})
|
|
587
|
+
})
|
|
588
|
+
.join("\n");
|
|
525
589
|
return [main, variantRow, addonRows].filter(Boolean).join("\n");
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
|
14
|
+
const typedName = order.customer?.name?.trim() || order.customer_name?.trim();
|
|
15
15
|
return {
|
|
16
|
-
name:
|
|
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.
|
|
20
|
+
tellerName: order.teller?.full_name?.trim() ??
|
|
21
|
+
order.user?.full_name?.trim() ??
|
|
22
|
+
null,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
|
-
const rawName = order.
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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.
|
|
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