@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.
- package/CHANGELOG.md +4 -0
- package/lib/compliance-export.js +614 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +5 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/store-credit.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|