@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.
- package/CHANGELOG.md +2 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-portal.js +359 -0
- package/lib/experiments.js +697 -0
- package/lib/index.js +14 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|