@blamejs/blamejs-shop 0.0.59 → 0.0.60

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,675 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.printReceipts
4
+ * @title Print receipts — three render surfaces for the warehouse + fulfillment console
5
+ *
6
+ * @intro
7
+ * Operators printing customer-facing paperwork have three target
8
+ * surfaces, each driven by a different downstream pipeline:
9
+ *
10
+ * thermal — 80mm ESC/POS column-formatted text. Sent directly
11
+ * to a thermal receipt printer (the warehouse pick
12
+ * slip, the in-counter customer copy). Default
13
+ * column count is 48 (the standard 80mm/12cpi
14
+ * shape); operators with a 58mm printer pass
15
+ * `paper_width_mm: 58` and the renderer narrows to
16
+ * 32 columns.
17
+ *
18
+ * html_pdf — A4 HTML emitted as a complete document the
19
+ * operator's worker pipes through a headless-Chrome
20
+ * PDF conversion. The HTML is self-contained
21
+ * (inlined CSS, no external assets) so the
22
+ * conversion has no network footprint.
23
+ *
24
+ * plain_text — plain-text email receipt body. CRLF line endings
25
+ * (RFC 5322 §2.3) so the bytes drop straight into
26
+ * an email payload without a downstream rewrap.
27
+ *
28
+ * Every text/HTML interpolation point passes through
29
+ * `b.template.escapeHtml` — operator-input fields (notes, sku,
30
+ * address lines) are equally at risk as customer-input fields when
31
+ * one of the two render formats is HTML. Plain-text output applies
32
+ * a stricter scrub: control bytes (U+0000..U+001F except newline +
33
+ * tab, plus U+007F) are stripped so a hostile note can't smuggle
34
+ * ANSI escapes into a terminal-rendered email preview.
35
+ *
36
+ * Surface:
37
+ *
38
+ * thermal({ order_id, paper_width_mm? })
39
+ * — returns the formatted ESC/POS text. `paper_width_mm`
40
+ * defaults to 80 (48 cols). Accepts 58 (32 cols). Other
41
+ * widths refused — operators with bespoke printers compose
42
+ * their own renderer; the primitive ships the two common
43
+ * widths.
44
+ *
45
+ * htmlPdf({ order_id, locale? })
46
+ * — returns a complete HTML document string suitable for
47
+ * headless-Chrome conversion. `locale` defaults to "en";
48
+ * pass any BCP-47-shape tag to switch the labels (the
49
+ * catalog ships en / es / de inline — unknown locales fall
50
+ * back to en).
51
+ *
52
+ * plainText({ order_id, locale? })
53
+ * — returns the plain-text email receipt body. CRLF newlines.
54
+ *
55
+ * previewBuffer({ order_id, format, locale? })
56
+ * — returns the rendered string without writing a row to
57
+ * receipt_prints. Operator console uses this for an
58
+ * on-screen preview before the real print. `format` is one
59
+ * of "thermal", "html_pdf", "plain_text".
60
+ *
61
+ * recordPrint({ order_id, format, printer_name?, occurred_at? })
62
+ * — renders the receipt, hashes the bytes, and appends one row
63
+ * to receipt_prints. Returns `{ id, byte_size, sha3_512,
64
+ * occurred_at }`. The render bytes themselves are NOT stored
65
+ * — the operator already holds them in the spool — only the
66
+ * integrity record.
67
+ *
68
+ * printsForOrder(order_id)
69
+ * — reads the audit log newest-first. Returns
70
+ * `[{ id, format, printer_name, locale, occurred_at,
71
+ * byte_size, sha3_512 }]`.
72
+ *
73
+ * @related b.template.escapeHtml, b.crypto.sha3Hash, b.guardUuid, b.uuid.v7
74
+ */
75
+
76
+ var bShop;
77
+ function _b() {
78
+ if (!bShop) bShop = require("./index");
79
+ return bShop.framework;
80
+ }
81
+
82
+ // ---- constants ----------------------------------------------------------
83
+
84
+ var VALID_FORMATS = { "thermal": true, "html_pdf": true, "plain_text": true };
85
+
86
+ // 80mm ESC/POS thermal printers ship at 48 columns @ 12cpi font A;
87
+ // 58mm narrow rolls ship at 32 columns. Other widths exist on
88
+ // bespoke industrial hardware — operators with one of those compose
89
+ // their own renderer downstream rather than smuggling a magic
90
+ // column-count through this primitive.
91
+ var THERMAL_WIDTHS = { 80: 48, 58: 32 };
92
+ var DEFAULT_PAPER_WIDTH_MM = 80;
93
+
94
+ // ESC/POS initialize + line-feed paper-cut. The thermal renderer
95
+ // emits these around the body so the printer reset state stays
96
+ // predictable across consecutive jobs and the paper auto-cuts at
97
+ // the end of each receipt.
98
+ //
99
+ // Sequences are the standard Epson TM-series mnemonic shapes (no
100
+ // vendor extension): ESC @ for init, GS V 1 for full-paper cut. A
101
+ // printer that doesn't speak the cut sequence simply prints the
102
+ // literal byte at the end of the spool; no firmware harm done.
103
+ var ESCPOS_INIT = "\x1B\x40"; // ESC @ — initialize printer
104
+ var ESCPOS_CUT = "\x1D\x56\x01"; // GS V 1 — partial paper cut
105
+
106
+ // BCP-47 shape: 2-3 alpha primary subtag, optional region/script
107
+ // subtags. Mirrors the gift-options / order-timeline shape.
108
+ var BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
109
+
110
+ // Plain-text scrub. Strip C0 control bytes (U+0000..U+001F) except
111
+ // LF (U+000A) + CR (U+000D) + TAB (U+0009), plus the lone DEL
112
+ // (U+007F). A hostile gift note carrying an ANSI escape sequence
113
+ // (`\x1B[2J`) would otherwise clear an operator's terminal when
114
+ // previewed via `less` / `more` on a CUPS spool capture.
115
+ var CONTROL_BYTES_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
116
+
117
+ // Per-locale labels for the operator-facing receipt header /
118
+ // totals. Shipped inline because the receipt vocabulary is tiny
119
+ // and stable; operators wanting a fuller catalog wrap this
120
+ // primitive with their own label map.
121
+ var LOCALE_LABELS = {
122
+ "en": {
123
+ receipt: "Receipt",
124
+ order: "Order",
125
+ placed: "Placed",
126
+ ship_to: "Ship to",
127
+ item: "Item",
128
+ qty: "Qty",
129
+ price: "Price",
130
+ line_total: "Total",
131
+ subtotal: "Subtotal",
132
+ discount: "Discount",
133
+ tax: "Tax",
134
+ shipping: "Shipping",
135
+ grand_total: "Grand total",
136
+ thanks: "Thank you for your order.",
137
+ },
138
+ "es": {
139
+ receipt: "Recibo",
140
+ order: "Pedido",
141
+ placed: "Realizado",
142
+ ship_to: "Enviar a",
143
+ item: "Articulo",
144
+ qty: "Cant",
145
+ price: "Precio",
146
+ line_total: "Total",
147
+ subtotal: "Subtotal",
148
+ discount: "Descuento",
149
+ tax: "Impuesto",
150
+ shipping: "Envio",
151
+ grand_total: "Total general",
152
+ thanks: "Gracias por su pedido.",
153
+ },
154
+ "de": {
155
+ receipt: "Quittung",
156
+ order: "Bestellung",
157
+ placed: "Aufgegeben",
158
+ ship_to: "Versand an",
159
+ item: "Artikel",
160
+ qty: "Menge",
161
+ price: "Preis",
162
+ line_total: "Summe",
163
+ subtotal: "Zwischensumme",
164
+ discount: "Rabatt",
165
+ tax: "Steuer",
166
+ shipping: "Versand",
167
+ grand_total: "Gesamtbetrag",
168
+ thanks: "Vielen Dank fuer Ihre Bestellung.",
169
+ },
170
+ };
171
+
172
+ // ---- validators ---------------------------------------------------------
173
+
174
+ function _uuid(s, label) {
175
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
176
+ catch (e) { throw new TypeError("printReceipts: " + label + " — " + (e && e.message || "invalid UUID")); }
177
+ }
178
+
179
+ function _locale(s) {
180
+ if (s == null) return "en";
181
+ if (typeof s !== "string" || !BCP47_RE.test(s)) {
182
+ throw new TypeError("printReceipts: locale must be a BCP-47-shape string (e.g. 'en-US')");
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _paperWidth(n) {
188
+ if (n == null) return DEFAULT_PAPER_WIDTH_MM;
189
+ if (!Number.isInteger(n) || !Object.prototype.hasOwnProperty.call(THERMAL_WIDTHS, String(n))) {
190
+ throw new TypeError("printReceipts: paper_width_mm must be one of 80, 58");
191
+ }
192
+ return n;
193
+ }
194
+
195
+ function _format(s) {
196
+ if (typeof s !== "string" || !VALID_FORMATS[s]) {
197
+ throw new TypeError("printReceipts: format must be one of 'thermal', 'html_pdf', 'plain_text'");
198
+ }
199
+ return s;
200
+ }
201
+
202
+ function _occurredAt(n) {
203
+ if (n == null) return Date.now();
204
+ if (!Number.isInteger(n) || n < 0) {
205
+ throw new TypeError("printReceipts: occurred_at must be a non-negative integer epoch-ms");
206
+ }
207
+ return n;
208
+ }
209
+
210
+ function _printerName(s) {
211
+ if (s == null) return null;
212
+ if (typeof s !== "string" || !s.length || s.length > 256) {
213
+ throw new TypeError("printReceipts: printer_name must be a 1..256 char string when provided");
214
+ }
215
+ return s;
216
+ }
217
+
218
+ function _resolveLocaleLabels(locale) {
219
+ var lc = locale.toLowerCase();
220
+ if (LOCALE_LABELS[lc]) return LOCALE_LABELS[lc];
221
+ var primary = lc.split("-")[0];
222
+ if (LOCALE_LABELS[primary]) return LOCALE_LABELS[primary];
223
+ return LOCALE_LABELS.en;
224
+ }
225
+
226
+ // ---- shared rendering helpers ------------------------------------------
227
+
228
+ // Format a (minor-units, ISO-4217-code) pair as a display string.
229
+ // Two-decimal currencies cover the vast majority of operator-facing
230
+ // receipts; zero-decimal currencies (JPY, KRW, etc.) get the raw
231
+ // integer. The split is by the operator's currency code — a future
232
+ // per-currency catalog can refine this without changing the
233
+ // primitive's surface.
234
+ var ZERO_DECIMAL_CURRENCIES = { "JPY": true, "KRW": true, "VND": true, "CLP": true, "ISK": true };
235
+
236
+ function _formatMoney(minor, currency) {
237
+ if (ZERO_DECIMAL_CURRENCIES[currency]) {
238
+ return String(minor) + " " + currency;
239
+ }
240
+ var major = Math.floor(minor / 100);
241
+ var cents = Math.abs(minor % 100);
242
+ var centsStr = cents < 10 ? "0" + cents : String(cents);
243
+ return major + "." + centsStr + " " + currency;
244
+ }
245
+
246
+ function _isoDate(epochMs) {
247
+ // YYYY-MM-DD HH:MM UTC. Stable across runners — never resorts to
248
+ // locale-aware Date formatting which would shift across host
249
+ // timezones.
250
+ var d = new Date(epochMs);
251
+ function _pad(n) { return n < 10 ? "0" + n : String(n); }
252
+ return d.getUTCFullYear() + "-" + _pad(d.getUTCMonth() + 1) + "-" + _pad(d.getUTCDate()) +
253
+ " " + _pad(d.getUTCHours()) + ":" + _pad(d.getUTCMinutes()) + " UTC";
254
+ }
255
+
256
+ function _scrubPlain(s) {
257
+ if (s == null) return "";
258
+ return String(s).replace(CONTROL_BYTES_RE, "");
259
+ }
260
+
261
+ // Pad a string to exactly `width` cols (right-pad with spaces, or
262
+ // truncate if too long). Used by the thermal renderer to align
263
+ // columns inside the fixed-width receipt.
264
+ function _padRight(s, width) {
265
+ var str = String(s == null ? "" : s);
266
+ if (str.length >= width) return str.slice(0, width);
267
+ var pad = width - str.length;
268
+ return str + " ".repeat(pad);
269
+ }
270
+
271
+ function _padLeft(s, width) {
272
+ var str = String(s == null ? "" : s);
273
+ if (str.length >= width) return str.slice(str.length - width);
274
+ var pad = width - str.length;
275
+ return " ".repeat(pad) + str;
276
+ }
277
+
278
+ // Center a string within `width`. Truncates if the input is already
279
+ // wider than the target.
280
+ function _center(s, width) {
281
+ var str = String(s == null ? "" : s);
282
+ if (str.length >= width) return str.slice(0, width);
283
+ var left = Math.floor((width - str.length) / 2);
284
+ var right = width - str.length - left;
285
+ return " ".repeat(left) + str + " ".repeat(right);
286
+ }
287
+
288
+ // Render the ship-to block as plain text lines. Recognises the
289
+ // common address-shape keys (name, line1, line2, city, region,
290
+ // postal_code, country) but tolerates a partial shape — the order
291
+ // primitive's `_shipTo` validator only requires `country`, so a
292
+ // minimal order on the test path renders with just that one line.
293
+ function _shipToLines(shipTo) {
294
+ if (!shipTo || typeof shipTo !== "object") return [];
295
+ var lines = [];
296
+ if (shipTo.name) lines.push(String(shipTo.name));
297
+ if (shipTo.line1) lines.push(String(shipTo.line1));
298
+ if (shipTo.line2) lines.push(String(shipTo.line2));
299
+ var cityLine = [];
300
+ if (shipTo.city) cityLine.push(String(shipTo.city));
301
+ if (shipTo.region) cityLine.push(String(shipTo.region));
302
+ if (shipTo.postal_code) cityLine.push(String(shipTo.postal_code));
303
+ if (cityLine.length) lines.push(cityLine.join(", "));
304
+ if (shipTo.country) lines.push(String(shipTo.country));
305
+ return lines;
306
+ }
307
+
308
+ // ---- factory ------------------------------------------------------------
309
+
310
+ function create(opts) {
311
+ opts = opts || {};
312
+ var query = opts.query;
313
+ if (!query) {
314
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
315
+ }
316
+ if (!opts.order || typeof opts.order.get !== "function") {
317
+ throw new TypeError("printReceipts.create: opts.order primitive is required");
318
+ }
319
+ var orderPrim = opts.order;
320
+
321
+ // Load + validate an order. Throws when the order_id is malformed
322
+ // (caller bug); throws when the row is missing (operator error —
323
+ // the print queue should never reach a deleted order).
324
+ async function _loadOrder(orderId) {
325
+ orderId = _uuid(orderId, "order_id");
326
+ var order = await orderPrim.get(orderId);
327
+ if (!order) {
328
+ throw new TypeError("printReceipts: order " + orderId + " not found");
329
+ }
330
+ return order;
331
+ }
332
+
333
+ // ---- format renderers ------------------------------------------------
334
+
335
+ function _renderThermal(order, paperWidthMm) {
336
+ var cols = THERMAL_WIDTHS[paperWidthMm];
337
+ var labels = LOCALE_LABELS.en; // thermal stays English — operator hardware reflects shop config
338
+ var lines = [];
339
+
340
+ lines.push(_center(labels.receipt.toUpperCase(), cols));
341
+ lines.push("=".repeat(cols));
342
+ lines.push(labels.order + ": " + String(order.id).slice(0, cols - labels.order.length - 2));
343
+ lines.push(labels.placed + ": " + _isoDate(Number(order.created_at)));
344
+ lines.push("");
345
+
346
+ // Ship-to block.
347
+ var shipLines = _shipToLines(order.ship_to);
348
+ if (shipLines.length) {
349
+ lines.push(labels.ship_to + ":");
350
+ for (var s = 0; s < shipLines.length; s += 1) {
351
+ lines.push(" " + shipLines[s]);
352
+ }
353
+ lines.push("");
354
+ }
355
+
356
+ // Line items. Layout (48-col): SKU (padded 18) | QTY (3) |
357
+ // PRICE (right-padded 12) | TOTAL (right-padded remainder).
358
+ // 32-col layout drops to: SKU (14) | QTY (3) | TOTAL (15).
359
+ lines.push("-".repeat(cols));
360
+ var skuW, qtyW, priceW, totalW;
361
+ if (cols === 48) {
362
+ skuW = 18; qtyW = 3; priceW = 12; totalW = cols - skuW - qtyW - priceW - 3;
363
+ lines.push(
364
+ _padRight(labels.item, skuW) + " " +
365
+ _padLeft(labels.qty, qtyW) + " " +
366
+ _padLeft(labels.price, priceW) + " " +
367
+ _padLeft(labels.line_total, totalW),
368
+ );
369
+ } else {
370
+ skuW = 14; qtyW = 3; totalW = cols - skuW - qtyW - 2;
371
+ lines.push(
372
+ _padRight(labels.item, skuW) + " " +
373
+ _padLeft(labels.qty, qtyW) + " " +
374
+ _padLeft(labels.line_total, totalW),
375
+ );
376
+ }
377
+ lines.push("-".repeat(cols));
378
+
379
+ var orderLines = order.lines || [];
380
+ for (var i = 0; i < orderLines.length; i += 1) {
381
+ var l = orderLines[i];
382
+ var sku = _scrubPlain(l.sku || "");
383
+ var qty = String(l.qty);
384
+ var lineTotal = _formatMoney(Number(l.line_total_minor || 0), l.unit_currency || order.currency);
385
+ if (cols === 48) {
386
+ var unit = _formatMoney(Number(l.unit_amount_minor || 0), l.unit_currency || order.currency);
387
+ lines.push(
388
+ _padRight(sku, skuW) + " " +
389
+ _padLeft(qty, qtyW) + " " +
390
+ _padLeft(unit, priceW) + " " +
391
+ _padLeft(lineTotal, totalW),
392
+ );
393
+ } else {
394
+ lines.push(
395
+ _padRight(sku, skuW) + " " +
396
+ _padLeft(qty, qtyW) + " " +
397
+ _padLeft(lineTotal, totalW),
398
+ );
399
+ }
400
+ }
401
+ lines.push("-".repeat(cols));
402
+
403
+ // Totals block. Each line: label left-padded, amount right-aligned.
404
+ function _totalLine(label, minor) {
405
+ var amount = _formatMoney(minor, order.currency);
406
+ var avail = cols - amount.length - 1;
407
+ return _padRight(label + ":", avail) + " " + amount;
408
+ }
409
+ lines.push(_totalLine(labels.subtotal, Number(order.subtotal_minor || 0)));
410
+ if (Number(order.discount_minor || 0) > 0) {
411
+ lines.push(_totalLine(labels.discount, -Number(order.discount_minor || 0)));
412
+ }
413
+ lines.push(_totalLine(labels.tax, Number(order.tax_minor || 0)));
414
+ lines.push(_totalLine(labels.shipping, Number(order.shipping_minor || 0)));
415
+ lines.push("=".repeat(cols));
416
+ lines.push(_totalLine(labels.grand_total, Number(order.grand_total_minor || 0)));
417
+ lines.push("");
418
+ lines.push(_center(labels.thanks, cols));
419
+
420
+ var body = lines.join("\n") + "\n";
421
+ return ESCPOS_INIT + body + "\n\n\n" + ESCPOS_CUT;
422
+ }
423
+
424
+ function _renderHtmlPdf(order, locale) {
425
+ var labels = _resolveLocaleLabels(locale);
426
+ var escapeHtml = _b().template.escapeHtml;
427
+
428
+ var shipLinesHtml = _shipToLines(order.ship_to).map(function (line) {
429
+ return "<div>" + escapeHtml(line) + "</div>";
430
+ }).join("");
431
+
432
+ var rowsHtml = "";
433
+ var orderLines = order.lines || [];
434
+ for (var i = 0; i < orderLines.length; i += 1) {
435
+ var l = orderLines[i];
436
+ var unit = _formatMoney(Number(l.unit_amount_minor || 0), l.unit_currency || order.currency);
437
+ var lineTotal = _formatMoney(Number(l.line_total_minor || 0), l.unit_currency || order.currency);
438
+ rowsHtml +=
439
+ "<tr>" +
440
+ "<td>" + escapeHtml(l.sku || "") + "</td>" +
441
+ "<td class=\"num\">" + escapeHtml(String(l.qty)) + "</td>" +
442
+ "<td class=\"num\">" + escapeHtml(unit) + "</td>" +
443
+ "<td class=\"num\">" + escapeHtml(lineTotal) + "</td>" +
444
+ "</tr>";
445
+ }
446
+
447
+ var discountRow = Number(order.discount_minor || 0) > 0
448
+ ? ("<tr><th>" + escapeHtml(labels.discount) + "</th>" +
449
+ "<td class=\"num\">-" + escapeHtml(_formatMoney(Number(order.discount_minor || 0), order.currency)) + "</td></tr>")
450
+ : "";
451
+
452
+ return "<!doctype html>\n" +
453
+ "<html lang=\"" + escapeHtml(locale) + "\">\n" +
454
+ "<head>\n" +
455
+ "<meta charset=\"utf-8\">\n" +
456
+ "<title>" + escapeHtml(labels.receipt) + " " + escapeHtml(order.id) + "</title>\n" +
457
+ "<style>\n" +
458
+ "@page { size: A4; margin: 18mm; }\n" +
459
+ "body { font-family: system-ui, sans-serif; color: #111; font-size: 11pt; }\n" +
460
+ "h1 { font-size: 18pt; margin: 0 0 8mm 0; }\n" +
461
+ ".meta { margin-bottom: 6mm; }\n" +
462
+ ".meta div { margin-bottom: 1mm; }\n" +
463
+ ".ship-to { margin-bottom: 6mm; }\n" +
464
+ "table { width: 100%; border-collapse: collapse; }\n" +
465
+ "th, td { padding: 2mm 1mm; border-bottom: 1px solid #ddd; text-align: left; }\n" +
466
+ "td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }\n" +
467
+ ".totals { margin-top: 6mm; }\n" +
468
+ ".totals th { text-align: left; font-weight: normal; }\n" +
469
+ ".totals tr.grand th, .totals tr.grand td { font-weight: bold; border-top: 2px solid #111; }\n" +
470
+ ".thanks { margin-top: 10mm; font-style: italic; }\n" +
471
+ "</style>\n" +
472
+ "</head>\n" +
473
+ "<body>\n" +
474
+ "<h1>" + escapeHtml(labels.receipt) + "</h1>\n" +
475
+ "<div class=\"meta\">\n" +
476
+ "<div><strong>" + escapeHtml(labels.order) + ":</strong> " + escapeHtml(order.id) + "</div>\n" +
477
+ "<div><strong>" + escapeHtml(labels.placed) + ":</strong> " + escapeHtml(_isoDate(Number(order.created_at))) + "</div>\n" +
478
+ "</div>\n" +
479
+ "<div class=\"ship-to\">\n" +
480
+ "<strong>" + escapeHtml(labels.ship_to) + ":</strong>\n" +
481
+ shipLinesHtml +
482
+ "</div>\n" +
483
+ "<table>\n" +
484
+ "<thead><tr>" +
485
+ "<th>" + escapeHtml(labels.item) + "</th>" +
486
+ "<th class=\"num\">" + escapeHtml(labels.qty) + "</th>" +
487
+ "<th class=\"num\">" + escapeHtml(labels.price) + "</th>" +
488
+ "<th class=\"num\">" + escapeHtml(labels.line_total) + "</th>" +
489
+ "</tr></thead>\n" +
490
+ "<tbody>" + rowsHtml + "</tbody>\n" +
491
+ "</table>\n" +
492
+ "<table class=\"totals\">\n" +
493
+ "<tr><th>" + escapeHtml(labels.subtotal) + "</th>" +
494
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.subtotal_minor || 0), order.currency)) + "</td></tr>\n" +
495
+ discountRow +
496
+ "<tr><th>" + escapeHtml(labels.tax) + "</th>" +
497
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.tax_minor || 0), order.currency)) + "</td></tr>\n" +
498
+ "<tr><th>" + escapeHtml(labels.shipping) + "</th>" +
499
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.shipping_minor || 0), order.currency)) + "</td></tr>\n" +
500
+ "<tr class=\"grand\"><th>" + escapeHtml(labels.grand_total) + "</th>" +
501
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.grand_total_minor || 0), order.currency)) + "</td></tr>\n" +
502
+ "</table>\n" +
503
+ "<div class=\"thanks\">" + escapeHtml(labels.thanks) + "</div>\n" +
504
+ "</body>\n" +
505
+ "</html>\n";
506
+ }
507
+
508
+ function _renderPlainText(order, locale) {
509
+ var labels = _resolveLocaleLabels(locale);
510
+ var lines = [];
511
+ lines.push(labels.receipt.toUpperCase());
512
+ lines.push("=".repeat(labels.receipt.length));
513
+ lines.push("");
514
+ lines.push(labels.order + ": " + _scrubPlain(order.id));
515
+ lines.push(labels.placed + ": " + _isoDate(Number(order.created_at)));
516
+ lines.push("");
517
+
518
+ var shipLines = _shipToLines(order.ship_to);
519
+ if (shipLines.length) {
520
+ lines.push(labels.ship_to + ":");
521
+ for (var s = 0; s < shipLines.length; s += 1) {
522
+ lines.push(" " + _scrubPlain(shipLines[s]));
523
+ }
524
+ lines.push("");
525
+ }
526
+
527
+ // Items: "<qty>x <sku> @ <unit> = <line_total>"
528
+ var orderLines = order.lines || [];
529
+ for (var i = 0; i < orderLines.length; i += 1) {
530
+ var l = orderLines[i];
531
+ var unit = _formatMoney(Number(l.unit_amount_minor || 0), l.unit_currency || order.currency);
532
+ var lineTotal = _formatMoney(Number(l.line_total_minor || 0), l.unit_currency || order.currency);
533
+ lines.push(String(l.qty) + "x " + _scrubPlain(l.sku || "") + " @ " + unit + " = " + lineTotal);
534
+ }
535
+ lines.push("");
536
+ lines.push(labels.subtotal + ": " + _formatMoney(Number(order.subtotal_minor || 0), order.currency));
537
+ if (Number(order.discount_minor || 0) > 0) {
538
+ lines.push(labels.discount + ": -" + _formatMoney(Number(order.discount_minor || 0), order.currency));
539
+ }
540
+ lines.push(labels.tax + ": " + _formatMoney(Number(order.tax_minor || 0), order.currency));
541
+ lines.push(labels.shipping + ": " + _formatMoney(Number(order.shipping_minor || 0), order.currency));
542
+ lines.push(labels.grand_total + ": " + _formatMoney(Number(order.grand_total_minor || 0), order.currency));
543
+ lines.push("");
544
+ lines.push(labels.thanks);
545
+
546
+ // CRLF line endings per RFC 5322 §2.3 — the bytes drop into an
547
+ // email payload directly without a downstream rewrap pass.
548
+ return lines.join("\r\n") + "\r\n";
549
+ }
550
+
551
+ // ---- dispatcher (shared by previewBuffer + recordPrint) --------------
552
+
553
+ async function _renderFor(order, format, locale, paperWidthMm) {
554
+ if (format === "thermal") return _renderThermal(order, paperWidthMm);
555
+ if (format === "html_pdf") return _renderHtmlPdf(order, locale);
556
+ if (format === "plain_text") return _renderPlainText(order, locale);
557
+ // _format() validator gates this — defensive throw retained so
558
+ // a hand-rolled caller bypassing the validator still gets a
559
+ // clear refusal.
560
+ throw new TypeError("printReceipts: unsupported format " + format);
561
+ }
562
+
563
+ // ---- public surface --------------------------------------------------
564
+
565
+ return {
566
+ VALID_FORMATS: Object.freeze(Object.keys(VALID_FORMATS)),
567
+ DEFAULT_PAPER_WIDTH_MM: DEFAULT_PAPER_WIDTH_MM,
568
+
569
+ thermal: async function (input) {
570
+ if (!input || typeof input !== "object") {
571
+ throw new TypeError("printReceipts.thermal: input object required");
572
+ }
573
+ var orderId = _uuid(input.order_id, "order_id");
574
+ var paperWidthMm = _paperWidth(input.paper_width_mm);
575
+ var order = await _loadOrder(orderId);
576
+ return _renderThermal(order, paperWidthMm);
577
+ },
578
+
579
+ htmlPdf: async function (input) {
580
+ if (!input || typeof input !== "object") {
581
+ throw new TypeError("printReceipts.htmlPdf: input object required");
582
+ }
583
+ var orderId = _uuid(input.order_id, "order_id");
584
+ var locale = _locale(input.locale);
585
+ var order = await _loadOrder(orderId);
586
+ return _renderHtmlPdf(order, locale);
587
+ },
588
+
589
+ plainText: async function (input) {
590
+ if (!input || typeof input !== "object") {
591
+ throw new TypeError("printReceipts.plainText: input object required");
592
+ }
593
+ var orderId = _uuid(input.order_id, "order_id");
594
+ var locale = _locale(input.locale);
595
+ var order = await _loadOrder(orderId);
596
+ return _renderPlainText(order, locale);
597
+ },
598
+
599
+ previewBuffer: async function (input) {
600
+ if (!input || typeof input !== "object") {
601
+ throw new TypeError("printReceipts.previewBuffer: input object required");
602
+ }
603
+ var orderId = _uuid(input.order_id, "order_id");
604
+ var format = _format(input.format);
605
+ var locale = _locale(input.locale);
606
+ var paperWidthMm = format === "thermal" ? _paperWidth(input.paper_width_mm) : DEFAULT_PAPER_WIDTH_MM;
607
+ var order = await _loadOrder(orderId);
608
+ return _renderFor(order, format, locale, paperWidthMm);
609
+ },
610
+
611
+ recordPrint: async function (input) {
612
+ if (!input || typeof input !== "object") {
613
+ throw new TypeError("printReceipts.recordPrint: input object required");
614
+ }
615
+ var orderId = _uuid(input.order_id, "order_id");
616
+ var format = _format(input.format);
617
+ var printerName = _printerName(input.printer_name);
618
+ var occurredAt = _occurredAt(input.occurred_at);
619
+ var locale = _locale(input.locale);
620
+ var paperWidthMm = format === "thermal" ? _paperWidth(input.paper_width_mm) : DEFAULT_PAPER_WIDTH_MM;
621
+
622
+ var order = await _loadOrder(orderId);
623
+ var rendered = await _renderFor(order, format, locale, paperWidthMm);
624
+ var bytes = Buffer.from(rendered, "utf8");
625
+ var byteSize = bytes.length;
626
+ var sha = _b().crypto.sha3Hash(bytes);
627
+
628
+ var id = _b().uuid.v7();
629
+ await query(
630
+ "INSERT INTO receipt_prints (id, order_id, format, printer_name, locale, " +
631
+ "occurred_at, byte_size, sha3_512) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
632
+ [id, orderId, format, printerName, locale, occurredAt, byteSize, sha],
633
+ );
634
+
635
+ return {
636
+ id: id,
637
+ order_id: orderId,
638
+ format: format,
639
+ printer_name: printerName,
640
+ locale: locale,
641
+ occurred_at: occurredAt,
642
+ byte_size: byteSize,
643
+ sha3_512: sha,
644
+ };
645
+ },
646
+
647
+ printsForOrder: async function (orderId) {
648
+ orderId = _uuid(orderId, "order_id");
649
+ var rows = (await query(
650
+ "SELECT id, order_id, format, printer_name, locale, occurred_at, " +
651
+ "byte_size, sha3_512 FROM receipt_prints WHERE order_id = ?1 " +
652
+ "ORDER BY occurred_at DESC, id DESC",
653
+ [orderId],
654
+ )).rows;
655
+ return rows.map(function (r) {
656
+ return {
657
+ id: r.id,
658
+ order_id: r.order_id,
659
+ format: r.format,
660
+ printer_name: r.printer_name == null ? null : r.printer_name,
661
+ locale: r.locale,
662
+ occurred_at: Number(r.occurred_at),
663
+ byte_size: Number(r.byte_size),
664
+ sha3_512: r.sha3_512,
665
+ };
666
+ });
667
+ },
668
+ };
669
+ }
670
+
671
+ module.exports = {
672
+ create: create,
673
+ VALID_FORMATS: Object.freeze(Object.keys(VALID_FORMATS)),
674
+ DEFAULT_PAPER_WIDTH_MM: DEFAULT_PAPER_WIDTH_MM,
675
+ };