@teincfood/core 0.7.6 → 0.7.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Local receipt generation — 1:1 port of TeincFoodBackend.Orders.Receipt
3
+ * Generates ESC/POS (80mm, 32 chars) and HTML from a SellerOrder snapshot.
4
+ * Works fully offline for queued DD-RRR-CCC orders without backend fetch.
5
+ *
6
+ * Backend source: lib/teinc_food_backend/orders/receipt.ex
7
+ * Keep this file in sync with backend when template changes.
8
+ */
9
+ const LINE_WIDTH = 32;
10
+ const ESC_INIT = "\x1B\x40";
11
+ const ESC_ALIGN_LEFT = "\x1B\x61\x00";
12
+ const ESC_ALIGN_CENTER = "\x1B\x61\x01";
13
+ const ESC_BOLD_ON = "\x1B\x45\x01";
14
+ const ESC_BOLD_OFF = "\x1B\x45\x00";
15
+ const GS_SIZE_NORMAL = "\x1D\x21\x00";
16
+ const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
17
+ const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
18
+ const LF = "\x0A";
19
+ const ESC_FEED_3 = "\x1B\x64\x03";
20
+ const GS_CUT_FULL = "\x1D\x56\x00";
21
+ function padR(text, width) {
22
+ if (text.length >= width)
23
+ return text.slice(0, width);
24
+ return text.padEnd(width, " ");
25
+ }
26
+ function currencySymbol(currency) {
27
+ switch (currency) {
28
+ case "GHS": return "₵";
29
+ case "USD": return "$";
30
+ case "EUR": return "€";
31
+ case "GBP": return "£";
32
+ default: return currency ? currency + " " : "";
33
+ }
34
+ }
35
+ function formatMoney(amountMinor, order) {
36
+ if (amountMinor == null)
37
+ return "---";
38
+ const sym = currencySymbol(order.currency_code || order.currency);
39
+ return `${sym}${(amountMinor / 100).toFixed(2)}`;
40
+ }
41
+ function formatDatetime(iso) {
42
+ if (!iso)
43
+ return "---";
44
+ try {
45
+ const d = new Date(iso);
46
+ // Backend: Calendar.strftime(dt, "%d %b %Y %H:%M") e.g. "03 Sep 2026 14:05"
47
+ const day = String(d.getDate()).padStart(2, "0");
48
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
49
+ const mon = months[d.getMonth()];
50
+ const year = d.getFullYear();
51
+ const hh = String(d.getHours()).padStart(2, "0");
52
+ const mm = String(d.getMinutes()).padStart(2, "0");
53
+ return `${day} ${mon} ${year} ${hh}:${mm}`;
54
+ }
55
+ catch {
56
+ return iso;
57
+ }
58
+ }
59
+ export function calloutNumber(order) {
60
+ const num = order.order_number || "";
61
+ if (!num)
62
+ return "----";
63
+ const last4 = num.slice(-4);
64
+ const n = parseInt(last4, 10);
65
+ if (Number.isNaN(n))
66
+ return last4;
67
+ return String(n);
68
+ }
69
+ function itemName(item) {
70
+ return item.menu_item?.name || "(deleted item)";
71
+ }
72
+ function addonName(addon) {
73
+ const a = addon;
74
+ if (a.addon?.name)
75
+ return a.addon.name;
76
+ if (a.name)
77
+ return a.name;
78
+ return "Add-on";
79
+ }
80
+ function orderTypeText(order) {
81
+ const t = order.order_type;
82
+ if (t === "takeout")
83
+ return "Takeout";
84
+ if (t === "dine_in")
85
+ return "Dine-in";
86
+ if (t === "delivery")
87
+ return "Delivery";
88
+ if (order.payment_mode === "pay_on_delivery")
89
+ return "Pay on Delivery";
90
+ if (order.payment_mode === "prepaid")
91
+ return "Prepaid";
92
+ return "Walk-in";
93
+ }
94
+ function paymentLineText(order) {
95
+ const m = order.pos_payment_method;
96
+ if (m)
97
+ return `Payment: ${m.charAt(0).toUpperCase() + m.slice(1)}`;
98
+ if (order.payment_mode === "pay_on_delivery")
99
+ return "Payment: Cash / Pay on Delivery";
100
+ if (order.payment_mode === "prepaid")
101
+ return "Payment: Prepaid (Online)";
102
+ return "Payment: Walk-in";
103
+ }
104
+ function escHtml(s) {
105
+ if (!s)
106
+ return "";
107
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
108
+ }
109
+ function businessAddress(business) {
110
+ const b = business;
111
+ if (!b)
112
+ return null;
113
+ if (b.location?.address)
114
+ return b.location.address;
115
+ if (b.address)
116
+ return b.address;
117
+ return null;
118
+ }
119
+ // ── ESC/POS ────────────────────────────────────────────────────────────
120
+ function doubleLineEscpos() { return "═".repeat(LINE_WIDTH) + LF; }
121
+ function singleLineEscpos() { return "─".repeat(LINE_WIDTH); }
122
+ function customerLineEscpos(order) {
123
+ if (order.customer_name)
124
+ return `Customer: ${order.customer_name}${LF}`;
125
+ if (order.customer_phone)
126
+ return `Phone: ${order.customer_phone}${LF}`;
127
+ if (order.user?.full_name)
128
+ return `Customer: ${order.user.full_name}${LF}`;
129
+ return null;
130
+ }
131
+ function orderTypeEscpos(order) { return orderTypeText(order); }
132
+ function paymentLineEscpos(order) { return paymentLineText(order); }
133
+ export function generateEscpos(order) {
134
+ const business = order.business;
135
+ const items = order.items || [];
136
+ const taxLines = (order.order_tax_lines ?? order.tax_lines ?? []);
137
+ const parts = [];
138
+ const push = (s) => { if (s != null)
139
+ parts.push(s); };
140
+ // Header — matches receipt.ex to_escpos/1
141
+ push(ESC_INIT);
142
+ push(ESC_ALIGN_CENTER);
143
+ push(GS_SIZE_DOUBLE_WH);
144
+ push(business?.name || "Teinc Food");
145
+ push(LF);
146
+ push(GS_SIZE_NORMAL);
147
+ const addr = businessAddress(business);
148
+ if (addr)
149
+ push(addr + LF);
150
+ const bPhone = business?.phone;
151
+ if (bPhone)
152
+ push(`Tel: ${bPhone}${LF}`);
153
+ push(GS_SIZE_NORMAL);
154
+ push(doubleLineEscpos());
155
+ // Order info
156
+ push(ESC_ALIGN_LEFT);
157
+ push(`Order: ${order.order_number}${LF}`);
158
+ // callout_box_escpos
159
+ push(ESC_ALIGN_CENTER);
160
+ push(GS_SIZE_DOUBLE_W);
161
+ push(`Callout: ${calloutNumber(order)}${LF}`);
162
+ push(GS_SIZE_NORMAL);
163
+ push(LF);
164
+ push(ESC_ALIGN_LEFT);
165
+ push(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
166
+ push(`Type: ${orderTypeEscpos(order)}${LF}`);
167
+ const custLine = customerLineEscpos(order);
168
+ if (custLine)
169
+ push(custLine);
170
+ push(LF);
171
+ // Barcode — CODE128 GS k 73
172
+ push(ESC_ALIGN_CENTER);
173
+ const data = order.order_number || "";
174
+ push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
175
+ push(LF);
176
+ // Items
177
+ push(ESC_ALIGN_LEFT);
178
+ push(`${padR("Item", 16)} Qty Price`);
179
+ push(LF);
180
+ push(singleLineEscpos());
181
+ push(LF);
182
+ for (const item of items) {
183
+ const qty = item.quantity || 1;
184
+ const price = formatMoney((item.unit_price ?? 0) * qty, order);
185
+ push(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
186
+ const addons = item.order_item_addons ?? item.addons ?? [];
187
+ for (const addon of addons) {
188
+ const aName = addonName(addon);
189
+ const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
190
+ const addonPrice = formatMoney(aPriceMinor, order);
191
+ push(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
192
+ }
193
+ }
194
+ push(singleLineEscpos());
195
+ push(LF);
196
+ // Subtotal section — mirrors subtotal_section_escpos/2
197
+ push(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
198
+ if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
199
+ push(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
200
+ }
201
+ for (const tax of taxLines) {
202
+ const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
203
+ const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
204
+ push(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
205
+ }
206
+ if (order.discount_minor && order.discount_minor > 0) {
207
+ push(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
208
+ }
209
+ push(ESC_BOLD_ON);
210
+ push(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
211
+ push(ESC_BOLD_OFF);
212
+ push(LF);
213
+ push(ESC_ALIGN_CENTER);
214
+ push(paymentLineEscpos(order));
215
+ push(LF);
216
+ // Footer
217
+ push(doubleLineEscpos());
218
+ push("Thank you for your order!" + LF);
219
+ push("Come back soon!" + LF);
220
+ push(ESC_FEED_3);
221
+ push(GS_CUT_FULL);
222
+ return parts.filter((p) => p != null).join("");
223
+ }
224
+ // ── HTML ───────────────────────────────────────────────────────────────
225
+ export function generateHtml(order, logoDataUri) {
226
+ const business = order.business;
227
+ const items = order.items || [];
228
+ const taxLines = ((order.order_tax_lines) ?? order.tax_lines ?? []);
229
+ const callout = calloutNumber(order);
230
+ const logoSrc = logoDataUri || business?.logo_url || "";
231
+ const hasLogo = !!logoSrc;
232
+ const businessName = escHtml(business?.name || "Teinc Food");
233
+ const addr = businessAddress(business);
234
+ const bPhone = business?.phone;
235
+ const itemRows = items.map((item) => {
236
+ const qty = item.quantity || 1;
237
+ // unit_price is already minor? backend format_money expects minor
238
+ const price = formatMoney((item.unit_price ?? 0) * qty, order);
239
+ const main = `<tr><td class="item-name">${escHtml(itemName(item))}</td><td class="item-qty">${qty}</td><td class="item-price">${price}</td></tr>`;
240
+ const addons = (item.order_item_addons ?? item.addons ?? []);
241
+ const addonRows = addons.map((addon) => {
242
+ const aName = escHtml(addonName(addon));
243
+ const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
244
+ 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>`;
245
+ }).join("\n");
246
+ return [main, addonRows].filter(Boolean).join("\n");
247
+ }).join("\n");
248
+ const taxRowsHtml = taxLines.map((tax) => {
249
+ const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
250
+ return `<tr><td class="label">${escHtml(tax.name || "")} ${escHtml(rateStr)}</td><td class="amount">${formatMoney(tax.tax_amount_minor, order)}</td></tr>`;
251
+ }).join("\n");
252
+ // Exact style from receipt.ex to_html/2
253
+ return `<!DOCTYPE html>
254
+ <html>
255
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
256
+ <style>
257
+ body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
258
+ .header{text-align:center;border-top:2px solid #333;border-bottom:2px solid #333;padding:8px 0;margin-bottom:12px;}
259
+ .header .logo{max-width:180px;max-height:60px;margin:4px auto;display:block;object-fit:contain;}
260
+ .header h2{margin:4px 0 0;font-size:14px;}
261
+ .header .address{font-size:11px;color:#555;margin:2px 0;}
262
+ .callout{border:3px double #333;text-align:center;padding:10px;margin:10px 0;}
263
+ .callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
264
+ .callout .label{font-size:10px;color:#555;margin:0;}
265
+ .order-info{font-size:12px;margin:8px 0;}
266
+ table{width:100%;font-size:12px;border-collapse:collapse;}
267
+ td{padding:2px 0;vertical-align:top;}
268
+ .item-name{width:50%;}
269
+ .item-qty{width:15%;text-align:center;}
270
+ .item-price{width:35%;text-align:right;}
271
+ .addon-row td{font-size:11px;color:#666;padding-left:12px;}
272
+ .addon-row td.addon-price{text-align:right;}
273
+ .totals{margin-top:8px;}
274
+ .totals td.label{text-align:left;width:70%;}
275
+ .totals td.amount{text-align:right;width:30%;}
276
+ .total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
277
+ .footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
278
+ @media print{body{max-width:80mm;padding:0;}}
279
+ </style></head>
280
+ <body>
281
+ <div class="header">
282
+ ${hasLogo ? `<img class="logo" src="${escHtml(logoSrc)}" alt="${businessName}" />` : ""}
283
+ <h2>${businessName}</h2>
284
+ <div class="address">${escHtml(addr || "")}</div>
285
+ ${bPhone ? `<div class="address">Tel: ${escHtml(bPhone)}</div>` : ""}
286
+ </div>
287
+ <div class="order-info">
288
+ <strong>Order:</strong> ${escHtml(order.order_number)}<br>
289
+ <strong>Date:</strong> ${escHtml(formatDatetime(order.inserted_at))}<br>
290
+ <strong>Type:</strong> ${escHtml(orderTypeText(order))}<br>
291
+ ${order.customer_name ? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>` : order.user?.full_name ? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>` : ""}
292
+ </div>
293
+ <div class="callout">
294
+ <div class="number">${escHtml(callout)}</div>
295
+ <div class="label">CALL OUT NUMBER</div>
296
+ </div>
297
+ <table>
298
+ <tr style="border-bottom:1px solid #ccc;"><td class="item-name"><strong>Item</strong></td><td class="item-qty"><strong>Qty</strong></td><td class="item-price"><strong>Price</strong></td></tr>
299
+ ${itemRows}
300
+ </table>
301
+ <div class="totals">
302
+ <table>
303
+ <tr><td class="label">Subtotal</td><td class="amount">${formatMoney(order.subtotal_minor, order)}</td></tr>
304
+ ${order.delivery_fee_minor ? `<tr><td class="label">Delivery Fee</td><td class="amount">${formatMoney(order.delivery_fee_minor, order)}</td></tr>` : ""}
305
+ ${taxRowsHtml}
306
+ ${order.discount_minor ? `<tr><td class="label">Discount</td><td class="amount">-${formatMoney(order.discount_minor, order)}</td></tr>` : ""}
307
+ <tr class="total-row"><td class="label">TOTAL</td><td class="amount">${formatMoney(order.total_minor, order)}</td></tr>
308
+ <tr><td colspan="2" style="text-align:center;padding-top:8px;">${escHtml(paymentLineText(order))}</td></tr>
309
+ </table>
310
+ </div>
311
+ <div class="footer">
312
+ Thank you for your order!<br>Come back soon!
313
+ </div>
314
+ </body></html>`;
315
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Printer service — fetch receipt from backend when online,
3
+ * otherwise generate locally from SellerOrder snapshot (backend-identical template).
4
+ * Sends ESC/POS via TcpSocketAdapter, falls back to PrintFileAdapter.
5
+ */
6
+ import { generateEscpos, generateHtml } from "./receipt";
7
+ import type { SellerOrder } from "../types/api/seller.api";
8
+ declare function fetchReceipt(orderId: string, format?: "html" | "escpos"): Promise<string>;
9
+ /**
10
+ * Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
11
+ */
12
+ export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null): string;
13
+ /**
14
+ * Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
15
+ * generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
16
+ * `order` must be a full SellerOrder snapshot; for online orders we attempt to
17
+ * fetch the authoritative receipt first so the business logo/address are server-rendered.
18
+ */
19
+ export declare function printOrderReceipt(order: SellerOrder, opts?: {
20
+ logoDataUri?: string | null;
21
+ }): Promise<void>;
22
+ export declare function sendToTcpPrinter(ip: string, port: number, text: string): Promise<void>;
23
+ export declare const printerService: {
24
+ fetchReceipt: typeof fetchReceipt;
25
+ generateLocalReceipt: typeof generateLocalReceipt;
26
+ printOrderReceipt: typeof printOrderReceipt;
27
+ sendToTcpPrinter: typeof sendToTcpPrinter;
28
+ generateEscpos: typeof generateEscpos;
29
+ generateHtml: typeof generateHtml;
30
+ };
31
+ export {};
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Printer service — fetch receipt from backend when online,
3
+ * otherwise generate locally from SellerOrder snapshot (backend-identical template).
4
+ * Sends ESC/POS via TcpSocketAdapter, falls back to PrintFileAdapter.
5
+ */
6
+ import { getHttpClient } from "../adapters/http";
7
+ import { getTcpAdapter, getPrintFileAdapter } from "../adapters/print";
8
+ import { getPrinterState } from "./store";
9
+ import { generateEscpos, generateHtml } from "./receipt";
10
+ async function fetchReceipt(orderId, format = "html") {
11
+ const http = getHttpClient();
12
+ // http adapter wraps axios; fallback to fetch if not set
13
+ if (http) {
14
+ const res = await http.get(`/orders/${orderId}/receipt?format=${format}`, {
15
+ headers: { Accept: format === "html" ? "text/html" : "text/plain" },
16
+ });
17
+ // http.get may return { data: string } or raw string; handle both
18
+ const data = res.data ?? res;
19
+ if (typeof data === "string")
20
+ return data;
21
+ return String(data);
22
+ }
23
+ // Fallback fetch using API_BASE_URL already injected
24
+ const { API_BASE_URL } = await import("../utils/constants");
25
+ const url = `${API_BASE_URL}/orders/${orderId}/receipt?format=${format}`;
26
+ const resp = await fetch(url, { headers: { Accept: format === "html" ? "text/html" : "text/plain" } });
27
+ if (!resp.ok) {
28
+ const body = await resp.text().catch(() => "");
29
+ throw new Error(`Failed to fetch receipt (${resp.status}): ${body.slice(0, 200)}`);
30
+ }
31
+ return resp.text();
32
+ }
33
+ /**
34
+ * Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
35
+ */
36
+ export function generateLocalReceipt(order, format = "escpos", logoDataUri) {
37
+ return format === "escpos" ? generateEscpos(order) : generateHtml(order, logoDataUri);
38
+ }
39
+ /**
40
+ * Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
41
+ * generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
42
+ * `order` must be a full SellerOrder snapshot; for online orders we attempt to
43
+ * fetch the authoritative receipt first so the business logo/address are server-rendered.
44
+ */
45
+ export async function printOrderReceipt(order, opts) {
46
+ const { autoPrint, printers, defaultPrinterId } = getPrinterState();
47
+ const needsTcp = !!defaultPrinterId && autoPrint !== false;
48
+ const printer = needsTcp ? printers.find((p) => p.id === defaultPrinterId) : null;
49
+ const isTcp = !!(printer && printer.type === "tcp");
50
+ // Try authoritative backend receipt first when online (includes logo data URI)
51
+ // For offline/queued orders we generate locally.
52
+ let escpos = null;
53
+ let html = null;
54
+ if (isTcp) {
55
+ try {
56
+ // Prefer backend escpos when we have a real UUID (not DD-RRR-CCC)
57
+ const isLocal = /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(order.order_number) || /^[0-9A-F-]{36}$/.test(order.id) === false && order.id === order.order_number;
58
+ if (!isLocal || order.id) {
59
+ try {
60
+ escpos = await fetchReceipt(order.id, "escpos");
61
+ }
62
+ catch {
63
+ // fall back to local
64
+ }
65
+ }
66
+ if (!escpos)
67
+ escpos = generateLocalReceipt(order, "escpos", opts?.logoDataUri ?? null);
68
+ const tcp = getTcpAdapter();
69
+ if (!tcp)
70
+ throw new Error("TCP adapter not configured");
71
+ await tcp.sendToTcpPrinter(printer.ip, printer.port, escpos);
72
+ return;
73
+ }
74
+ catch (e) {
75
+ console.debug("[print] TCP print failed, falling back to HTML", e);
76
+ }
77
+ }
78
+ // Fallback: HTML via PrintFileAdapter or local share
79
+ try {
80
+ try {
81
+ html = await fetchReceipt(order.id, "html");
82
+ }
83
+ catch {
84
+ // offline
85
+ }
86
+ if (!html)
87
+ html = generateLocalReceipt(order, "html", opts?.logoDataUri ?? null);
88
+ const printAdapter = getPrintFileAdapter();
89
+ if (printAdapter) {
90
+ const { uri } = await printAdapter.printToFileAsync(html);
91
+ if (printAdapter.isSharingAvailable && printAdapter.shareAsync) {
92
+ const available = await printAdapter.isSharingAvailable();
93
+ if (available)
94
+ await printAdapter.shareAsync(uri, { dialogTitle: "Print Receipt", mimeType: "application/pdf" });
95
+ }
96
+ return;
97
+ }
98
+ // No adapter — throw so host can show preview fallback
99
+ throw new Error("PrintFileAdapter not configured — use generateLocalReceipt + WebView preview");
100
+ }
101
+ catch (e) {
102
+ console.debug("[print] HTML print failed", e);
103
+ throw e;
104
+ }
105
+ }
106
+ export async function sendToTcpPrinter(ip, port, text) {
107
+ const tcp = getTcpAdapter();
108
+ if (!tcp)
109
+ throw new Error("TCP socket not available");
110
+ return tcp.sendToTcpPrinter(ip, port, text);
111
+ }
112
+ export const printerService = {
113
+ fetchReceipt,
114
+ generateLocalReceipt,
115
+ printOrderReceipt,
116
+ sendToTcpPrinter,
117
+ generateEscpos,
118
+ generateHtml,
119
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Printer store — local device config, persisted via KV driver.
3
+ * Business app invariant: printers are device-local, never cleared on logout.
4
+ */
5
+ import type { PrinterConfig, PrinterState } from "./types";
6
+ export declare function hydratePrinterStore(): Promise<void>;
7
+ export declare function getPrinterState(): PrinterState;
8
+ export declare function subscribePrinter(listener: () => void): () => void;
9
+ export declare function getPrinterSnapshot(): PrinterState;
10
+ export declare function setDefaultPrinter(id: string | null): void;
11
+ export declare function addPrinter(printer: PrinterConfig): void;
12
+ export declare function updatePrinter(id: string, updates: Partial<PrinterConfig>): void;
13
+ export declare function removePrinter(id: string): void;
14
+ export declare function setAutoPrint(enabled: boolean): void;
15
+ export declare const printerStore: {
16
+ getState: typeof getPrinterState;
17
+ subscribe: typeof subscribePrinter;
18
+ hydrate: typeof hydratePrinterStore;
19
+ setDefaultPrinter: typeof setDefaultPrinter;
20
+ addPrinter: typeof addPrinter;
21
+ updatePrinter: typeof updatePrinter;
22
+ removePrinter: typeof removePrinter;
23
+ setAutoPrint: typeof setAutoPrint;
24
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Printer store — local device config, persisted via KV driver.
3
+ * Business app invariant: printers are device-local, never cleared on logout.
4
+ */
5
+ import { getKVDriver } from "../adapters/kv";
6
+ import { STORAGE_KEYS } from "../utils/constants";
7
+ const listeners = new Set();
8
+ let state = {
9
+ defaultPrinterId: null,
10
+ printers: [],
11
+ autoPrint: false,
12
+ };
13
+ let hydrated = false;
14
+ function notify() {
15
+ listeners.forEach((l) => l());
16
+ }
17
+ async function persist() {
18
+ try {
19
+ const kv = getKVDriver();
20
+ await kv.setItem(STORAGE_KEYS.PRINTER_SETTINGS, JSON.stringify(state));
21
+ }
22
+ catch { }
23
+ }
24
+ export async function hydratePrinterStore() {
25
+ if (hydrated)
26
+ return;
27
+ hydrated = true;
28
+ try {
29
+ const kv = getKVDriver();
30
+ const raw = await kv.getItem(STORAGE_KEYS.PRINTER_SETTINGS);
31
+ if (raw) {
32
+ const parsed = JSON.parse(raw);
33
+ state = {
34
+ defaultPrinterId: parsed.defaultPrinterId ?? null,
35
+ printers: Array.isArray(parsed.printers) ? parsed.printers : [],
36
+ autoPrint: !!parsed.autoPrint,
37
+ };
38
+ notify();
39
+ }
40
+ }
41
+ catch { }
42
+ }
43
+ export function getPrinterState() {
44
+ return state;
45
+ }
46
+ export function subscribePrinter(listener) {
47
+ listeners.add(listener);
48
+ return () => listeners.delete(listener);
49
+ }
50
+ export function getPrinterSnapshot() {
51
+ return state;
52
+ }
53
+ export function setDefaultPrinter(id) {
54
+ state = { ...state, defaultPrinterId: id };
55
+ notify();
56
+ void persist();
57
+ }
58
+ export function addPrinter(printer) {
59
+ state = { ...state, printers: [...state.printers, printer] };
60
+ notify();
61
+ void persist();
62
+ }
63
+ export function updatePrinter(id, updates) {
64
+ state = {
65
+ ...state,
66
+ printers: state.printers.map((p) => (p.id === id ? { ...p, ...updates } : p)),
67
+ };
68
+ notify();
69
+ void persist();
70
+ }
71
+ export function removePrinter(id) {
72
+ state = {
73
+ ...state,
74
+ printers: state.printers.filter((p) => p.id !== id),
75
+ defaultPrinterId: state.defaultPrinterId === id ? null : state.defaultPrinterId,
76
+ };
77
+ notify();
78
+ void persist();
79
+ }
80
+ export function setAutoPrint(enabled) {
81
+ state = { ...state, autoPrint: enabled };
82
+ notify();
83
+ void persist();
84
+ }
85
+ export const printerStore = {
86
+ getState: getPrinterState,
87
+ subscribe: subscribePrinter,
88
+ hydrate: hydratePrinterStore,
89
+ setDefaultPrinter,
90
+ addPrinter,
91
+ updatePrinter,
92
+ removePrinter,
93
+ setAutoPrint,
94
+ };
@@ -0,0 +1,12 @@
1
+ export interface PrinterConfig {
2
+ id: string;
3
+ name: string;
4
+ ip: string;
5
+ port: number;
6
+ type: "tcp" | "bluetooth";
7
+ }
8
+ export interface PrinterState {
9
+ defaultPrinterId: string | null;
10
+ printers: PrinterConfig[];
11
+ autoPrint: boolean;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -13,7 +13,7 @@ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandSt
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { generateEventId } from "../utils/uuid";
15
15
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
16
- import { subscribeConnectivity } from "../adapters/connectivity";
16
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
17
17
  const MAX_RETRIES = 5;
18
18
  const QUEUED_STATUS_BY_COMMAND = {
19
19
  "order:accept": "accepted",
@@ -65,6 +65,10 @@ function buildOptimisticEvent(command) {
65
65
  function buildOptimisticOrder(command, payload) {
66
66
  const items = payload.items ?? [];
67
67
  const totalMinor = payload.total_minor ?? 0;
68
+ const subtotalMinor = payload.subtotal_minor ?? totalMinor;
69
+ const taxMinor = payload.tax_minor ?? 0;
70
+ const taxLines = payload.tax_lines ?? [];
71
+ const currencyCode = payload.currency_code ?? "GHS";
68
72
  const now = new Date().toISOString();
69
73
  const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
70
74
  return {
@@ -85,20 +89,20 @@ function buildOptimisticOrder(command, payload) {
85
89
  is_paid: payload.pos_payment_method === "cash",
86
90
  reference: null,
87
91
  amount_minor: totalMinor,
88
- currency: "GHS",
92
+ currency: currencyCode,
89
93
  paid_at: payload.pos_payment_method === "cash" ? now : null,
90
94
  },
91
95
  payment_mode: "pos_cash",
92
96
  total_minor: totalMinor,
93
- subtotal_minor: totalMinor,
94
- delivery_fee_minor: 0,
95
- tax_minor: 0,
96
- tip_minor: null,
97
- discount_minor: 0,
98
- promo_code: null,
99
- tax_lines: [],
100
- currency: "GHS",
101
- currency_code: "GHS",
97
+ subtotal_minor: subtotalMinor,
98
+ delivery_fee_minor: payload.delivery_fee_minor ?? 0,
99
+ tax_minor: taxMinor,
100
+ tip_minor: payload.tip_minor ?? null,
101
+ discount_minor: payload.discount_minor ?? 0,
102
+ promo_code: payload.promo_code ?? null,
103
+ tax_lines: taxLines,
104
+ currency: currencyCode,
105
+ currency_code: currencyCode,
102
106
  business_id: command.business_id,
103
107
  business: null,
104
108
  delivery_address: null,
@@ -147,7 +151,8 @@ class CommandQueueService {
147
151
  // timeout-induced `unreachable` flip, then the next successful probe sets
148
152
  // `reachable`). No polling — event-driven via connectivity state.
149
153
  try {
150
- subscribeConnectivity((s) => {
154
+ subscribeConnectivity(() => {
155
+ const s = getConnectivityState();
151
156
  if (s.isCloudReachable === "reachable" && s.isOnline) {
152
157
  this.replay().catch(() => { });
153
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.7.6",
3
+ "version": "0.7.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",