@blamejs/blamejs-shop 0.0.62 → 0.0.64

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,618 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.invoiceRenderer
4
+ * @title Invoice renderer — formal accounting invoices over an order
5
+ *
6
+ * @intro
7
+ * `printReceipts` covers the post-sale paperwork the warehouse +
8
+ * counter need (thermal slip, customer copy, email receipt body).
9
+ * This primitive is the *other* paperwork: the formal accounting
10
+ * invoice with a sequential invoice number, tax breakdown, payment
11
+ * terms, and a due date.
12
+ *
13
+ * The two are intentionally distinct surfaces:
14
+ *
15
+ * printReceipts — render-on-demand, the bytes never need to
16
+ * be the same on re-render (the warehouse
17
+ * re-prints a lost slip whenever).
18
+ * invoiceRenderer — mints an invoice_number once per call,
19
+ * records the integrity hash, and the
20
+ * operator's accounting tree expects every
21
+ * issued number to map 1:1 to a row in this
22
+ * table forever (gap-free sequences are a
23
+ * hard requirement in most VAT/GST
24
+ * jurisdictions).
25
+ *
26
+ * Surface:
27
+ *
28
+ * nextInvoiceNumber({ series? })
29
+ * — atomically advances the per-series counter and returns the
30
+ * formatted invoice number string (`INV-YYYY-NNNNNN` by
31
+ * default). `series` defaults to "DEFAULT"; operators with
32
+ * per-region books pass e.g. "EU" / "US-WHOLESALE" and the
33
+ * primitive maintains an independent counter per series.
34
+ * The format string is fixed in v1 — operators wanting a
35
+ * different shape wrap this primitive with their own
36
+ * formatter; the underlying integer is always returned in
37
+ * the result so the wrap is cheap.
38
+ *
39
+ * renderHtml({ order_id, locale?, series?, due_days? })
40
+ * — mints a fresh invoice number, then returns the complete
41
+ * HTML invoice (self-contained, A4 @page rule, inlined CSS)
42
+ * suitable for headless-Chrome PDF conversion. `locale`
43
+ * defaults to "en"; `due_days` defaults to 30 (Net 30 — the
44
+ * common B2B payment term). Returns `{ invoice_number,
45
+ * series, locale, due_days, due_at, generated_at, html }`.
46
+ * Does NOT record the integrity row — `recordInvoice` is the
47
+ * deliberate audit step (the operator's worker may discard
48
+ * a render mid-PDF-conversion).
49
+ *
50
+ * recordInvoice({ order_id, invoice_number, html_size, sha3_512 })
51
+ * — appends the audit row. Returns `{ id, order_id,
52
+ * invoice_number, html_size, sha3_512, generated_at }`. The
53
+ * invoice_number is UNIQUE — a duplicate write (e.g. retry
54
+ * after a partial failure) refuses with a clear message
55
+ * rather than silently overwriting.
56
+ *
57
+ * invoicesForOrder(order_id)
58
+ * — reads the audit log newest-first. Returns the same row
59
+ * shape as recordInvoice's return value.
60
+ *
61
+ * getInvoiceByNumber(invoice_number)
62
+ * — single-row lookup by the operator-facing invoice number.
63
+ * Returns `null` when no row matches.
64
+ *
65
+ * Every text interpolation point passes through
66
+ * `b.template.escapeHtml`. Operator-input fields (shop name,
67
+ * address) and customer-input fields (ship-to, sku notes) are
68
+ * equally at risk when the render surface is HTML; the primitive
69
+ * does not distinguish trust levels.
70
+ *
71
+ * Operator+customer addresses on the invoice:
72
+ * - The customer (bill-to) address comes from the order's
73
+ * `ship_to` block — most shops use a single buyer address for
74
+ * both surfaces. Operators with split bill-to/ship-to data
75
+ * wrap this primitive and override the rendered block.
76
+ * - The operator (issuer) address comes from shop_config keys
77
+ * `invoice.issuer_name` + `invoice.issuer_address_lines`
78
+ * (Array<string>) + `invoice.issuer_tax_id`. Each key falls
79
+ * back to a sensible default when the operator hasn't set it;
80
+ * the audit row is still issued so the invoice number stays
81
+ * sequential.
82
+ *
83
+ * @related b.template.escapeHtml, b.crypto.sha3Hash, b.guardUuid, b.uuid.v7
84
+ */
85
+
86
+ var bShop;
87
+ function _b() {
88
+ if (!bShop) bShop = require("./index");
89
+ return bShop.framework;
90
+ }
91
+
92
+ // ---- constants ----------------------------------------------------------
93
+
94
+ var DEFAULT_SERIES = "DEFAULT";
95
+ var DEFAULT_DUE_DAYS = 30; // Net 30 — common B2B payment term
96
+ var MAX_DUE_DAYS = 365; // anything beyond a year is almost certainly an operator typo
97
+
98
+ var SERIES_RE = /^[A-Za-z0-9_-]{1,64}$/;
99
+ var BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
100
+
101
+ // Zero-decimal currencies (rendered as bare integers). Matches the
102
+ // printReceipts catalog — the two render surfaces ship with the same
103
+ // list so the displayed totals agree across the two artifacts.
104
+ var ZERO_DECIMAL_CURRENCIES = { "JPY": true, "KRW": true, "VND": true, "CLP": true, "ISK": true };
105
+
106
+ // Per-locale labels for the invoice surface. Stable, narrow
107
+ // vocabulary (operator-facing accounting terms) — operators wanting
108
+ // a fuller catalog wrap this primitive with their own label map.
109
+ var LOCALE_LABELS = {
110
+ "en": {
111
+ invoice: "Invoice",
112
+ invoice_no: "Invoice no.",
113
+ issued: "Issued",
114
+ due: "Due",
115
+ bill_to: "Bill to",
116
+ from: "From",
117
+ tax_id: "Tax ID",
118
+ item: "Item",
119
+ qty: "Qty",
120
+ price: "Price",
121
+ line_total: "Total",
122
+ subtotal: "Subtotal",
123
+ discount: "Discount",
124
+ tax: "Tax",
125
+ shipping: "Shipping",
126
+ grand_total: "Grand total",
127
+ payment_terms: "Payment terms",
128
+ net_days: "Net {N}",
129
+ thanks: "Thank you for your business.",
130
+ },
131
+ "es": {
132
+ invoice: "Factura",
133
+ invoice_no: "Factura n.",
134
+ issued: "Emitida",
135
+ due: "Vence",
136
+ bill_to: "Facturar a",
137
+ from: "De",
138
+ tax_id: "NIF",
139
+ item: "Articulo",
140
+ qty: "Cant",
141
+ price: "Precio",
142
+ line_total: "Total",
143
+ subtotal: "Subtotal",
144
+ discount: "Descuento",
145
+ tax: "Impuesto",
146
+ shipping: "Envio",
147
+ grand_total: "Total general",
148
+ payment_terms: "Condiciones de pago",
149
+ net_days: "Neto {N}",
150
+ thanks: "Gracias por su confianza.",
151
+ },
152
+ "de": {
153
+ invoice: "Rechnung",
154
+ invoice_no: "Rechnungsnr.",
155
+ issued: "Ausgestellt",
156
+ due: "Faellig",
157
+ bill_to: "Rechnungsempfaenger",
158
+ from: "Von",
159
+ tax_id: "USt-IdNr.",
160
+ item: "Artikel",
161
+ qty: "Menge",
162
+ price: "Preis",
163
+ line_total: "Summe",
164
+ subtotal: "Zwischensumme",
165
+ discount: "Rabatt",
166
+ tax: "Steuer",
167
+ shipping: "Versand",
168
+ grand_total: "Gesamtbetrag",
169
+ payment_terms: "Zahlungsbedingungen",
170
+ net_days: "Netto {N}",
171
+ thanks: "Vielen Dank fuer Ihr Vertrauen.",
172
+ },
173
+ };
174
+
175
+ // ---- validators ---------------------------------------------------------
176
+
177
+ function _uuid(s, label) {
178
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
179
+ catch (e) { throw new TypeError("invoiceRenderer: " + label + " — " + (e && e.message || "invalid UUID")); }
180
+ }
181
+
182
+ function _series(s) {
183
+ if (s == null) return DEFAULT_SERIES;
184
+ if (typeof s !== "string" || !SERIES_RE.test(s)) {
185
+ throw new TypeError("invoiceRenderer: series must match /^[A-Za-z0-9_-]{1,64}$/");
186
+ }
187
+ return s;
188
+ }
189
+
190
+ function _locale(s) {
191
+ if (s == null) return "en";
192
+ if (typeof s !== "string" || !BCP47_RE.test(s)) {
193
+ throw new TypeError("invoiceRenderer: locale must be a BCP-47-shape string (e.g. 'en-US')");
194
+ }
195
+ return s;
196
+ }
197
+
198
+ function _dueDays(n) {
199
+ if (n == null) return DEFAULT_DUE_DAYS;
200
+ if (!Number.isInteger(n) || n < 0 || n > MAX_DUE_DAYS) {
201
+ throw new TypeError("invoiceRenderer: due_days must be an integer 0..365");
202
+ }
203
+ return n;
204
+ }
205
+
206
+ function _invoiceNumber(s) {
207
+ if (typeof s !== "string" || s.length < 1 || s.length > 128) {
208
+ throw new TypeError("invoiceRenderer: invoice_number must be a 1..128 char string");
209
+ }
210
+ return s;
211
+ }
212
+
213
+ function _htmlSize(n) {
214
+ if (!Number.isInteger(n) || n < 0) {
215
+ throw new TypeError("invoiceRenderer: html_size must be a non-negative integer");
216
+ }
217
+ return n;
218
+ }
219
+
220
+ function _sha3(s) {
221
+ if (typeof s !== "string" || s.length !== 128 || !/^[0-9a-f]{128}$/.test(s)) {
222
+ throw new TypeError("invoiceRenderer: sha3_512 must be a 128-char lowercase hex string");
223
+ }
224
+ return s;
225
+ }
226
+
227
+ function _resolveLocaleLabels(locale) {
228
+ var lc = locale.toLowerCase();
229
+ if (LOCALE_LABELS[lc]) return LOCALE_LABELS[lc];
230
+ var primary = lc.split("-")[0];
231
+ if (LOCALE_LABELS[primary]) return LOCALE_LABELS[primary];
232
+ return LOCALE_LABELS.en;
233
+ }
234
+
235
+ // ---- shared rendering helpers ------------------------------------------
236
+
237
+ function _formatMoney(minor, currency) {
238
+ if (ZERO_DECIMAL_CURRENCIES[currency]) {
239
+ return String(minor) + " " + currency;
240
+ }
241
+ var major = Math.floor(minor / 100);
242
+ var cents = Math.abs(minor % 100);
243
+ var centsStr = cents < 10 ? "0" + cents : String(cents);
244
+ return major + "." + centsStr + " " + currency;
245
+ }
246
+
247
+ function _isoDate(epochMs) {
248
+ // YYYY-MM-DD UTC — invoice dates are by-day not by-second; the
249
+ // operator's accounting tree only cares about the calendar day.
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
+ }
254
+
255
+ function _shipToLines(shipTo) {
256
+ if (!shipTo || typeof shipTo !== "object") return [];
257
+ var lines = [];
258
+ if (shipTo.name) lines.push(String(shipTo.name));
259
+ if (shipTo.line1) lines.push(String(shipTo.line1));
260
+ if (shipTo.line2) lines.push(String(shipTo.line2));
261
+ var cityLine = [];
262
+ if (shipTo.city) cityLine.push(String(shipTo.city));
263
+ if (shipTo.region) cityLine.push(String(shipTo.region));
264
+ if (shipTo.postal_code) cityLine.push(String(shipTo.postal_code));
265
+ if (cityLine.length) lines.push(cityLine.join(", "));
266
+ if (shipTo.country) lines.push(String(shipTo.country));
267
+ return lines;
268
+ }
269
+
270
+ function _formatInvoiceNumber(seq, epochMs) {
271
+ // `INV-YYYY-NNNNNN`. Zero-pad to 6 digits — covers ~1M invoices
272
+ // per series per year, which is plenty for a small/mid shop;
273
+ // operators with higher volume wrap this primitive and supply a
274
+ // custom formatter.
275
+ var year = new Date(epochMs).getUTCFullYear();
276
+ var padded = String(seq);
277
+ while (padded.length < 6) padded = "0" + padded;
278
+ return "INV-" + year + "-" + padded;
279
+ }
280
+
281
+ // ---- factory ------------------------------------------------------------
282
+
283
+ function create(opts) {
284
+ opts = opts || {};
285
+ var query = opts.query;
286
+ if (!query) {
287
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
288
+ }
289
+ if (!opts.order || typeof opts.order.get !== "function") {
290
+ throw new TypeError("invoiceRenderer.create: opts.order primitive is required");
291
+ }
292
+ var orderPrim = opts.order;
293
+
294
+ // Atomically advance the per-series counter. CAS-shape UPDATE:
295
+ // read the current next_value, then UPDATE WHERE next_value = ?
296
+ // and retry on a zero-change result. Two concurrent calls cannot
297
+ // mint the same number — the loser sees a stale next_value, the
298
+ // UPDATE matches zero rows, and the loop re-reads.
299
+ async function _advanceSeries(series) {
300
+ for (var attempts = 0; attempts < 16; attempts += 1) {
301
+ var existing = (await query(
302
+ "SELECT next_value FROM invoice_sequence WHERE series = ?1",
303
+ [series],
304
+ )).rows;
305
+ if (!existing.length) {
306
+ // Seed row at 1 — if another worker raced us to seed, the
307
+ // INSERT will throw on the PK and the next loop iteration
308
+ // reads the existing row.
309
+ try {
310
+ await query(
311
+ "INSERT INTO invoice_sequence (series, next_value, updated_at) VALUES (?1, ?2, ?3)",
312
+ [series, 2, Date.now()],
313
+ );
314
+ return 1;
315
+ } catch (_e) {
316
+ continue;
317
+ }
318
+ }
319
+ var current = Number(existing[0].next_value);
320
+ var info = await query(
321
+ "UPDATE invoice_sequence SET next_value = ?1, updated_at = ?2 " +
322
+ "WHERE series = ?3 AND next_value = ?4",
323
+ [current + 1, Date.now(), series, current],
324
+ );
325
+ if (Number(info.rowCount) === 1) return current;
326
+ }
327
+ throw new Error("invoiceRenderer: sequence advance for series " + JSON.stringify(series) + " contended 16 times");
328
+ }
329
+
330
+ async function _loadOrder(orderId) {
331
+ orderId = _uuid(orderId, "order_id");
332
+ var order = await orderPrim.get(orderId);
333
+ if (!order) {
334
+ throw new TypeError("invoiceRenderer: order " + orderId + " not found");
335
+ }
336
+ return order;
337
+ }
338
+
339
+ // Resolve the operator (issuer) block. Each key falls back to a
340
+ // sensible default so a fresh shop with no config can still issue
341
+ // an invoice; operators set the keys via the shop_config admin
342
+ // surface to make the rendered output match their letterhead.
343
+ async function _resolveIssuer() {
344
+ async function _get(key, fallback) {
345
+ var r = (await query("SELECT value_json FROM shop_config WHERE key = ?1", [key])).rows;
346
+ if (!r.length) return fallback;
347
+ try { return JSON.parse(r[0].value_json); }
348
+ catch (_e) { return fallback; }
349
+ }
350
+ return {
351
+ name: await _get("invoice.issuer_name", ""),
352
+ address_lines: await _get("invoice.issuer_address_lines", []),
353
+ tax_id: await _get("invoice.issuer_tax_id", ""),
354
+ };
355
+ }
356
+
357
+ function _renderHtmlBody(order, invoiceNumber, locale, dueDays, generatedAt, dueAt, issuer) {
358
+ var labels = _resolveLocaleLabels(locale);
359
+ var escapeHtml = _b().template.escapeHtml;
360
+
361
+ var billToLinesHtml = _shipToLines(order.ship_to).map(function (line) {
362
+ return "<div>" + escapeHtml(line) + "</div>";
363
+ }).join("");
364
+
365
+ var issuerLinesHtml = "";
366
+ if (issuer.name) {
367
+ issuerLinesHtml += "<div><strong>" + escapeHtml(String(issuer.name)) + "</strong></div>";
368
+ }
369
+ if (Array.isArray(issuer.address_lines)) {
370
+ for (var ai = 0; ai < issuer.address_lines.length; ai += 1) {
371
+ issuerLinesHtml += "<div>" + escapeHtml(String(issuer.address_lines[ai])) + "</div>";
372
+ }
373
+ }
374
+ if (issuer.tax_id) {
375
+ issuerLinesHtml += "<div>" + escapeHtml(labels.tax_id) + ": " + escapeHtml(String(issuer.tax_id)) + "</div>";
376
+ }
377
+
378
+ var rowsHtml = "";
379
+ var orderLines = order.lines || [];
380
+ for (var i = 0; i < orderLines.length; i += 1) {
381
+ var l = orderLines[i];
382
+ var unit = _formatMoney(Number(l.unit_amount_minor || 0), l.unit_currency || order.currency);
383
+ var lineTotal = _formatMoney(Number(l.line_total_minor || 0), l.unit_currency || order.currency);
384
+ rowsHtml +=
385
+ "<tr>" +
386
+ "<td>" + escapeHtml(l.sku || "") + "</td>" +
387
+ "<td class=\"num\">" + escapeHtml(String(l.qty)) + "</td>" +
388
+ "<td class=\"num\">" + escapeHtml(unit) + "</td>" +
389
+ "<td class=\"num\">" + escapeHtml(lineTotal) + "</td>" +
390
+ "</tr>";
391
+ }
392
+
393
+ var discountRow = Number(order.discount_minor || 0) > 0
394
+ ? ("<tr><th>" + escapeHtml(labels.discount) + "</th>" +
395
+ "<td class=\"num\">-" + escapeHtml(_formatMoney(Number(order.discount_minor || 0), order.currency)) + "</td></tr>")
396
+ : "";
397
+
398
+ var paymentTermsLabel = labels.net_days.replace("{N}", String(dueDays));
399
+
400
+ return "<!doctype html>\n" +
401
+ "<html lang=\"" + escapeHtml(locale) + "\">\n" +
402
+ "<head>\n" +
403
+ "<meta charset=\"utf-8\">\n" +
404
+ "<title>" + escapeHtml(labels.invoice) + " " + escapeHtml(invoiceNumber) + "</title>\n" +
405
+ "<style>\n" +
406
+ "@page { size: A4; margin: 18mm; }\n" +
407
+ "body { font-family: system-ui, sans-serif; color: #111; font-size: 11pt; }\n" +
408
+ "h1 { font-size: 22pt; margin: 0 0 4mm 0; }\n" +
409
+ ".invoice-meta { display: flex; justify-content: space-between; margin-bottom: 8mm; }\n" +
410
+ ".invoice-meta .right { text-align: right; }\n" +
411
+ ".parties { display: flex; justify-content: space-between; margin-bottom: 8mm; }\n" +
412
+ ".parties .block { width: 48%; }\n" +
413
+ ".parties .block strong.label { display: block; margin-bottom: 2mm; }\n" +
414
+ "table { width: 100%; border-collapse: collapse; }\n" +
415
+ "th, td { padding: 2mm 1mm; border-bottom: 1px solid #ddd; text-align: left; }\n" +
416
+ "td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }\n" +
417
+ ".totals { margin-top: 6mm; }\n" +
418
+ ".totals th { text-align: left; font-weight: normal; }\n" +
419
+ ".totals tr.grand th, .totals tr.grand td { font-weight: bold; border-top: 2px solid #111; }\n" +
420
+ ".terms { margin-top: 10mm; }\n" +
421
+ ".thanks { margin-top: 10mm; font-style: italic; }\n" +
422
+ "</style>\n" +
423
+ "</head>\n" +
424
+ "<body>\n" +
425
+ "<h1>" + escapeHtml(labels.invoice) + "</h1>\n" +
426
+ "<div class=\"invoice-meta\">\n" +
427
+ "<div>\n" +
428
+ "<div><strong>" + escapeHtml(labels.invoice_no) + ":</strong> " + escapeHtml(invoiceNumber) + "</div>\n" +
429
+ "<div><strong>" + escapeHtml(labels.issued) + ":</strong> " + escapeHtml(_isoDate(generatedAt)) + "</div>\n" +
430
+ "</div>\n" +
431
+ "<div class=\"right\">\n" +
432
+ "<div><strong>" + escapeHtml(labels.due) + ":</strong> " + escapeHtml(_isoDate(dueAt)) + "</div>\n" +
433
+ "</div>\n" +
434
+ "</div>\n" +
435
+ "<div class=\"parties\">\n" +
436
+ "<div class=\"block\">\n" +
437
+ "<strong class=\"label\">" + escapeHtml(labels.from) + ":</strong>\n" +
438
+ issuerLinesHtml +
439
+ "</div>\n" +
440
+ "<div class=\"block\">\n" +
441
+ "<strong class=\"label\">" + escapeHtml(labels.bill_to) + ":</strong>\n" +
442
+ billToLinesHtml +
443
+ "</div>\n" +
444
+ "</div>\n" +
445
+ "<table>\n" +
446
+ "<thead><tr>" +
447
+ "<th>" + escapeHtml(labels.item) + "</th>" +
448
+ "<th class=\"num\">" + escapeHtml(labels.qty) + "</th>" +
449
+ "<th class=\"num\">" + escapeHtml(labels.price) + "</th>" +
450
+ "<th class=\"num\">" + escapeHtml(labels.line_total) + "</th>" +
451
+ "</tr></thead>\n" +
452
+ "<tbody>" + rowsHtml + "</tbody>\n" +
453
+ "</table>\n" +
454
+ "<table class=\"totals\">\n" +
455
+ "<tr><th>" + escapeHtml(labels.subtotal) + "</th>" +
456
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.subtotal_minor || 0), order.currency)) + "</td></tr>\n" +
457
+ discountRow +
458
+ "<tr><th>" + escapeHtml(labels.tax) + "</th>" +
459
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.tax_minor || 0), order.currency)) + "</td></tr>\n" +
460
+ "<tr><th>" + escapeHtml(labels.shipping) + "</th>" +
461
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.shipping_minor || 0), order.currency)) + "</td></tr>\n" +
462
+ "<tr class=\"grand\"><th>" + escapeHtml(labels.grand_total) + "</th>" +
463
+ "<td class=\"num\">" + escapeHtml(_formatMoney(Number(order.grand_total_minor || 0), order.currency)) + "</td></tr>\n" +
464
+ "</table>\n" +
465
+ "<div class=\"terms\">\n" +
466
+ "<strong>" + escapeHtml(labels.payment_terms) + ":</strong> " + escapeHtml(paymentTermsLabel) + "\n" +
467
+ "</div>\n" +
468
+ "<div class=\"thanks\">" + escapeHtml(labels.thanks) + "</div>\n" +
469
+ "</body>\n" +
470
+ "</html>\n";
471
+ }
472
+
473
+ // ---- public surface --------------------------------------------------
474
+
475
+ return {
476
+ DEFAULT_SERIES: DEFAULT_SERIES,
477
+ DEFAULT_DUE_DAYS: DEFAULT_DUE_DAYS,
478
+
479
+ nextInvoiceNumber: async function (input) {
480
+ input = input || {};
481
+ var series = _series(input.series);
482
+ var now = Date.now();
483
+ var seq = await _advanceSeries(series);
484
+ var invoiceNumber = _formatInvoiceNumber(seq, now);
485
+ return {
486
+ invoice_number: invoiceNumber,
487
+ series: series,
488
+ sequence: seq,
489
+ generated_at: now,
490
+ };
491
+ },
492
+
493
+ renderHtml: async function (input) {
494
+ if (!input || typeof input !== "object") {
495
+ throw new TypeError("invoiceRenderer.renderHtml: input object required");
496
+ }
497
+ var orderId = _uuid(input.order_id, "order_id");
498
+ var locale = _locale(input.locale);
499
+ var series = _series(input.series);
500
+ var dueDays = _dueDays(input.due_days);
501
+
502
+ var order = await _loadOrder(orderId);
503
+ var issuer = await _resolveIssuer();
504
+
505
+ var generatedAt = Date.now();
506
+ var dueAt = generatedAt + dueDays * 24 * 60 * 60 * 1000;
507
+
508
+ var seq = await _advanceSeries(series);
509
+ var invoiceNumber = _formatInvoiceNumber(seq, generatedAt);
510
+
511
+ var html = _renderHtmlBody(order, invoiceNumber, locale, dueDays, generatedAt, dueAt, issuer);
512
+ return {
513
+ invoice_number: invoiceNumber,
514
+ series: series,
515
+ locale: locale,
516
+ due_days: dueDays,
517
+ due_at: dueAt,
518
+ generated_at: generatedAt,
519
+ html: html,
520
+ };
521
+ },
522
+
523
+ recordInvoice: async function (input) {
524
+ if (!input || typeof input !== "object") {
525
+ throw new TypeError("invoiceRenderer.recordInvoice: input object required");
526
+ }
527
+ var orderId = _uuid(input.order_id, "order_id");
528
+ var invoiceNumber = _invoiceNumber(input.invoice_number);
529
+ var htmlSize = _htmlSize(input.html_size);
530
+ var sha3 = _sha3(input.sha3_512);
531
+
532
+ // Verify the order exists — the FK would catch this at INSERT
533
+ // time too, but the upfront check returns a readable error
534
+ // rather than the engine's opaque constraint message.
535
+ await _loadOrder(orderId);
536
+
537
+ // Derive the series from the invoice_number when it matches
538
+ // the default `INV-YYYY-NNNNNN` shape, else fall back to
539
+ // "DEFAULT". Operators with bespoke formats can pass the
540
+ // series explicitly via the optional input field.
541
+ var series = DEFAULT_SERIES;
542
+ if (input.series != null) {
543
+ series = _series(input.series);
544
+ }
545
+
546
+ var id = _b().uuid.v7();
547
+ var generatedAt = Date.now();
548
+ try {
549
+ await query(
550
+ "INSERT INTO invoice_renderings (id, order_id, series, invoice_number, " +
551
+ "html_size, sha3_512, generated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
552
+ [id, orderId, series, invoiceNumber, htmlSize, sha3, generatedAt],
553
+ );
554
+ } catch (e) {
555
+ if (/UNIQUE/i.test(String(e && e.message))) {
556
+ throw new TypeError("invoiceRenderer: invoice_number " + JSON.stringify(invoiceNumber) + " already recorded");
557
+ }
558
+ throw e;
559
+ }
560
+ return {
561
+ id: id,
562
+ order_id: orderId,
563
+ series: series,
564
+ invoice_number: invoiceNumber,
565
+ html_size: htmlSize,
566
+ sha3_512: sha3,
567
+ generated_at: generatedAt,
568
+ };
569
+ },
570
+
571
+ invoicesForOrder: async function (orderId) {
572
+ orderId = _uuid(orderId, "order_id");
573
+ var rows = (await query(
574
+ "SELECT id, order_id, series, invoice_number, html_size, sha3_512, generated_at " +
575
+ "FROM invoice_renderings WHERE order_id = ?1 " +
576
+ "ORDER BY generated_at DESC, id DESC",
577
+ [orderId],
578
+ )).rows;
579
+ return rows.map(function (r) {
580
+ return {
581
+ id: r.id,
582
+ order_id: r.order_id,
583
+ series: r.series,
584
+ invoice_number: r.invoice_number,
585
+ html_size: Number(r.html_size),
586
+ sha3_512: r.sha3_512,
587
+ generated_at: Number(r.generated_at),
588
+ };
589
+ });
590
+ },
591
+
592
+ getInvoiceByNumber: async function (invoiceNumber) {
593
+ invoiceNumber = _invoiceNumber(invoiceNumber);
594
+ var rows = (await query(
595
+ "SELECT id, order_id, series, invoice_number, html_size, sha3_512, generated_at " +
596
+ "FROM invoice_renderings WHERE invoice_number = ?1",
597
+ [invoiceNumber],
598
+ )).rows;
599
+ if (!rows.length) return null;
600
+ var r = rows[0];
601
+ return {
602
+ id: r.id,
603
+ order_id: r.order_id,
604
+ series: r.series,
605
+ invoice_number: r.invoice_number,
606
+ html_size: Number(r.html_size),
607
+ sha3_512: r.sha3_512,
608
+ generated_at: Number(r.generated_at),
609
+ };
610
+ },
611
+ };
612
+ }
613
+
614
+ module.exports = {
615
+ create: create,
616
+ DEFAULT_SERIES: DEFAULT_SERIES,
617
+ DEFAULT_DUE_DAYS: DEFAULT_DUE_DAYS,
618
+ };