@teincfood/core 0.9.3 → 0.9.5

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.
@@ -7,6 +7,7 @@
7
7
  * Keep this file in sync with backend when template changes.
8
8
  */
9
9
  import type { SellerOrder } from "../types/api/seller.api";
10
+ export declare function escposText(s: string | null | undefined): string;
10
11
  export declare function calloutNumber(order: SellerOrder): string;
11
12
  export declare function generateEscpos(order: SellerOrder): string;
12
13
  export declare function generateHtmlAsync(order: SellerOrder, opts?: {
@@ -18,9 +18,43 @@ const ESC_BOLD_OFF = "\x1B\x45\x00";
18
18
  const GS_SIZE_NORMAL = "\x1D\x21\x00";
19
19
  const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
20
20
  const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
21
+ const GS_BAR_HEIGHT = "\x1D\x68\x3C";
22
+ const GS_BAR_WIDTH = "\x1D\x77\x02";
21
23
  const LF = "\x0A";
22
24
  const ESC_FEED_3 = "\x1B\x64\x03";
23
25
  const GS_CUT_FULL = "\x1D\x56\x00";
26
+ /**
27
+ * Thermal printers speak bytes, not Unicode. Control sequences are pushed
28
+ * raw, but every human-readable text segment must pass through here first:
29
+ * known symbols map to ASCII, anything else non-ASCII becomes "?".
30
+ * Without this, ₵/═/─/accents print as gibberish (UTF-8 bytes read as CP437).
31
+ */
32
+ const ESCPOS_REPLACEMENTS = [
33
+ [/[₵]/g, "GHc"],
34
+ [/[€]/g, "EUR"],
35
+ [/[£]/g, "GBP"],
36
+ [/[—–]/g, "-"],
37
+ [/[•·]/g, "*"],
38
+ [/[═╬]/g, "="],
39
+ [/[─│]/g, "-"],
40
+ [/[“”]/g, '"'],
41
+ [/[‘’`]/g, "'"],
42
+ [/[…]/g, "..."],
43
+ [/[×]/g, "x"],
44
+ [/[°]/g, "deg"],
45
+ [/ /g, " "],
46
+ ];
47
+ export function escposText(s) {
48
+ if (s == null)
49
+ return "";
50
+ let out = String(s);
51
+ for (const [re, sub] of ESCPOS_REPLACEMENTS)
52
+ out = out.replace(re, sub);
53
+ // Strip any remaining non-ASCII (accents, emoji, CJK) — the printer cannot
54
+ // render them and would print garbage bytes.
55
+ out = out.replace(/[^\x20-\x7E\n]/g, "?");
56
+ return out;
57
+ }
24
58
  function padR(text, width) {
25
59
  if (text.length >= width)
26
60
  return text.slice(0, width);
@@ -60,14 +94,19 @@ function formatDatetime(iso) {
60
94
  }
61
95
  }
62
96
  export function calloutNumber(order) {
97
+ // The backend assigns a real callout_number — always prefer it.
98
+ const direct = order.callout_number;
99
+ if (typeof direct === "number" && Number.isFinite(direct))
100
+ return String(Math.abs(direct));
63
101
  const num = order.order_number || "";
64
102
  if (!num)
65
103
  return "----";
66
- const last4 = num.slice(-4);
67
- const n = parseInt(last4, 10);
68
- if (Number.isNaN(n))
69
- return last4;
70
- return String(n);
104
+ // Strip non-digits first: local DD-RRR-CCC numbers end in "-CCC" and
105
+ // parseInt("-005") would give -5 (the old "-5" bug).
106
+ const digits = num.slice(-4).replace(/\D/g, "");
107
+ if (!digits)
108
+ return num.slice(-4);
109
+ return String(parseInt(digits, 10));
71
110
  }
72
111
  function itemName(item) {
73
112
  return item.menu_item?.name || "(deleted item)";
@@ -115,8 +154,19 @@ function businessAddress(business) {
115
154
  return null;
116
155
  if (b.location?.address)
117
156
  return b.location.address;
118
- if (b.address)
157
+ if (typeof b.address === "string")
119
158
  return b.address;
159
+ // Desktop snapshots carry a structured address object — flatten it
160
+ // (printing the object itself would render "[object Object]").
161
+ if (b.address && typeof b.address === "object") {
162
+ if (b.address.fullAddress)
163
+ return b.address.fullAddress;
164
+ if (b.address.full_address)
165
+ return b.address.full_address;
166
+ const parts = [b.address.street, b.address.city].filter(Boolean);
167
+ if (parts.length)
168
+ return parts.join(", ");
169
+ }
120
170
  return null;
121
171
  }
122
172
  function businessLogoUrl(order) {
@@ -165,39 +215,47 @@ export function generateEscpos(order) {
165
215
  const parts = [];
166
216
  const push = (s) => { if (s != null)
167
217
  parts.push(s); };
218
+ // Sanitized text push: every human-readable segment goes through escposText
219
+ // (control codes and barcode data use raw push).
220
+ const pushT = (s) => { if (s != null)
221
+ parts.push(escposText(s)); };
168
222
  // Header — matches receipt.ex to_escpos/1
223
+ // NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
224
+ // a raster pipeline (GS v 0). HTML receipts embed the logo instead.
169
225
  push(ESC_INIT);
170
226
  push(ESC_ALIGN_CENTER);
171
227
  push(GS_SIZE_DOUBLE_WH);
172
- push(business?.name || "Teinc Food");
228
+ pushT(business?.name || "Teinc Food");
173
229
  push(LF);
174
230
  push(GS_SIZE_NORMAL);
175
231
  const addr = businessAddress(business);
176
232
  if (addr)
177
- push(addr + LF);
233
+ pushT(addr + LF);
178
234
  const bPhone = business?.phone;
179
235
  if (bPhone)
180
- push(`Tel: ${bPhone}${LF}`);
236
+ pushT(`Tel: ${bPhone}${LF}`);
181
237
  push(GS_SIZE_NORMAL);
182
- push(doubleLineEscpos());
238
+ pushT(doubleLineEscpos());
183
239
  // Order info
184
240
  push(ESC_ALIGN_LEFT);
185
- push(`Order: ${order.order_number}${LF}`);
241
+ pushT(`Order: ${order.order_number}${LF}`);
186
242
  // callout_box_escpos
187
243
  push(ESC_ALIGN_CENTER);
188
244
  push(GS_SIZE_DOUBLE_W);
189
- push(`Callout: ${calloutNumber(order)}${LF}`);
245
+ pushT(`Callout: ${calloutNumber(order)}${LF}`);
190
246
  push(GS_SIZE_NORMAL);
191
247
  push(LF);
192
248
  push(ESC_ALIGN_LEFT);
193
- push(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
194
- push(`Type: ${orderTypeEscpos(order)}${LF}`);
249
+ pushT(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
250
+ pushT(`Type: ${orderTypeEscpos(order)}${LF}`);
195
251
  const custLine = customerLineEscpos(order);
196
252
  if (custLine)
197
- push(custLine);
253
+ pushT(custLine);
198
254
  push(LF);
199
- // Barcode — CODE128 GS k 73
255
+ // Barcode — CODE128 GS k 73, sized down (default height is huge).
200
256
  push(ESC_ALIGN_CENTER);
257
+ push(GS_BAR_HEIGHT);
258
+ push(GS_BAR_WIDTH);
201
259
  const data = order.order_number || "";
202
260
  push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
203
261
  push(LF);
@@ -205,44 +263,44 @@ export function generateEscpos(order) {
205
263
  push(ESC_ALIGN_LEFT);
206
264
  push(`${padR("Item", 16)} Qty Price`);
207
265
  push(LF);
208
- push(singleLineEscpos());
266
+ pushT(singleLineEscpos());
209
267
  push(LF);
210
268
  for (const item of items) {
211
269
  const qty = item.quantity || 1;
212
270
  const price = formatMoney((item.unit_price ?? 0) * qty, order);
213
- push(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
271
+ pushT(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
214
272
  const addons = item.order_item_addons ?? item.addons ?? [];
215
273
  for (const addon of addons) {
216
274
  const aName = addonName(addon);
217
275
  const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
218
276
  const addonPrice = formatMoney(aPriceMinor, order);
219
- push(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
277
+ pushT(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
220
278
  }
221
279
  }
222
- push(singleLineEscpos());
280
+ pushT(singleLineEscpos());
223
281
  push(LF);
224
282
  // Subtotal section — mirrors subtotal_section_escpos/2
225
- push(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
283
+ pushT(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
226
284
  if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
227
- push(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
285
+ pushT(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
228
286
  }
229
287
  for (const tax of taxLines) {
230
288
  const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
231
289
  const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
232
- push(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
290
+ pushT(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
233
291
  }
234
292
  if (order.discount_minor && order.discount_minor > 0) {
235
- push(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
293
+ pushT(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
236
294
  }
237
295
  push(ESC_BOLD_ON);
238
- push(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
296
+ pushT(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
239
297
  push(ESC_BOLD_OFF);
240
298
  push(LF);
241
299
  push(ESC_ALIGN_CENTER);
242
- push(paymentLineEscpos(order));
300
+ pushT(paymentLineEscpos(order));
243
301
  push(LF);
244
302
  // Footer
245
- push(doubleLineEscpos());
303
+ pushT(doubleLineEscpos());
246
304
  push("Thank you for your order!" + LF);
247
305
  push("Come back soon!" + LF);
248
306
  push(ESC_FEED_3);
@@ -13,6 +13,7 @@ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandSt
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./transition-errors";
16
+ import { assertBusinessOpenForCreate } from "../utils/business-hours";
16
17
  import { extractApiErrorCode } from "../utils/error";
17
18
  import { generateEventId } from "../utils/uuid";
18
19
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
@@ -203,6 +204,14 @@ class CommandQueueService {
203
204
  * Returns an optimistic domain event so the caller can update the UI.
204
205
  */
205
206
  async enqueue(command) {
207
+ // Queue-time hours gate: a closed business can never legally sync an
208
+ // order:create, so fail fast with BusinessHoursError instead of queuing
209
+ // an optimistic order that would sit pending forever. Other command
210
+ // types (transitions, payments, delivery) are unaffected so in-progress
211
+ // orders can still be completed while closed.
212
+ if (command.command_type === "order:create") {
213
+ await assertBusinessOpenForCreate(command.business_id);
214
+ }
206
215
  const db = getSyncDatabase();
207
216
  const preparedCommand = injectLocalOrderNumber(command);
208
217
  await insertCommand(db, { command: preparedCommand, status: "pending" });
@@ -149,6 +149,20 @@ export class SyncEngine {
149
149
  return events;
150
150
  }
151
151
  catch (error) {
152
+ // Definitive hours-guard rejection (thrown by createLocalOrder before
153
+ // any HTTP): the business is closed. Surface it immediately — do not
154
+ // mark cloud unreachable and do not fall back to the outbox, which
155
+ // would either re-throw from the enqueue gate or mask a real decision
156
+ // as a connectivity problem.
157
+ const code = errorCodeOf(error);
158
+ if (code === "BUSINESS_CLOSED" || code === "HOURS_NOT_SYNCED") {
159
+ syncLogger.warn("SyncEngine", "Command blocked: business closed", {
160
+ command_type: command.command_type,
161
+ command_id: command.command_id,
162
+ code,
163
+ });
164
+ throw error;
165
+ }
152
166
  // Definitive server rejection (4xx): the backend answered with a real
153
167
  // decision (e.g. INVALID_TRANSITION, PAYMENT_REQUIRED, FORBIDDEN).
154
168
  // Surface it instead of masking it as a connectivity problem and
@@ -159,7 +173,6 @@ export class SyncEngine {
159
173
  ? error.status
160
174
  : undefined;
161
175
  if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
162
- const code = errorCodeOf(error);
163
176
  syncLogger.warn("SyncEngine", "Cloud command rejected by server", {
164
177
  command_type: command.command_type,
165
178
  command_id: command.command_id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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",