@blamejs/blamejs-shop 0.0.72 → 0.0.76

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,1021 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.vendorInvoices
4
+ * @title Vendor invoices — operator-facing accounts-payable record
5
+ * of bills RECEIVED FROM vendors
6
+ *
7
+ * @intro
8
+ * A vendor invoice is the operator's record of a bill they received
9
+ * from one of their vendors (a supplier, a service provider, a
10
+ * freight carrier). Distinct from `invoiceRenderer`, which renders
11
+ * the operator-to-customer invoice — this primitive sits on the
12
+ * accounts-payable side of the ledger.
13
+ *
14
+ * The operator transcribes (or imports) the vendor's invoice into
15
+ * this table verbatim: line items, totals, due date, related POs.
16
+ * `reconcileAgainstPOs` computes the variance summary between the
17
+ * invoice's billed amounts and the related POs' captured costs +
18
+ * received quantities so the operator can spot a vendor over-billing
19
+ * before the payment goes out. Once the invoice is approved, the
20
+ * operator records the payment with a `markPaid` carrying the
21
+ * payment_ref + paid_minor — partial pay is first-class (operator
22
+ * only pays the undisputed portion while a dispute is open).
23
+ *
24
+ * FSM:
25
+ *
26
+ * received -> approved -> paid
27
+ * | |
28
+ * +-> disputed (operator pushes back on amount / line items)
29
+ * +-> voided (vendor reissued / duplicate / cancelled)
30
+ *
31
+ * Disputed + voided are terminal — the operator either records a
32
+ * follow-up invoice (vendor reissued under a new number) or, for
33
+ * disputed, the resolution is recorded out of band and the original
34
+ * invoice stays disputed in the audit trail.
35
+ *
36
+ * Composes:
37
+ * - vendors — validates `vendor_slug` exists + is not
38
+ * archived before recording an invoice
39
+ * against it. Injected as `opts.vendors`.
40
+ * Optional (when absent the primitive skips
41
+ * the vendor-existence check; tests that
42
+ * don't wire vendors still run).
43
+ * - purchaseOrders — used by `reconcileAgainstPOs` to fetch
44
+ * PO + line state for the variance math.
45
+ * Injected as `opts.purchaseOrders`.
46
+ * Optional — when absent, `reconcileAgainstPOs`
47
+ * refuses with a clear error code so the
48
+ * caller can either wire the handle or
49
+ * manage reconciliation out of band.
50
+ * - b.uuid.v7 — invoice PK (sortable; B-tree locality)
51
+ * - b.guardUuid — strict UUID validation on every invoice_id
52
+ * read
53
+ *
54
+ * Surface:
55
+ * recordInvoice({ vendor_slug, invoice_number, invoice_date,
56
+ * due_date, amount_minor, currency, line_items,
57
+ * related_po_ids? })
58
+ * getInvoice(invoice_id)
59
+ * invoicesForVendor({ vendor_slug, status? })
60
+ * unpaidInvoices({ due_within_days? })
61
+ * markApproved({ invoice_id, approved_by })
62
+ * markPaid({ invoice_id, payment_ref, paid_at, paid_minor })
63
+ * markDisputed({ invoice_id, reason })
64
+ * markVoided({ invoice_id, reason })
65
+ * reconcileAgainstPOs({ invoice_id })
66
+ * agingReport({ as_of, vendor_slug? })
67
+ * metricsForVendor({ vendor_slug, from, to })
68
+ *
69
+ * Storage: `migrations-d1/0193_vendor_invoices.sql` —
70
+ * `vendor_invoices` (status FSM enforced as CHECK enum +
71
+ * per-status nullable contract, UNIQUE(vendor_slug, invoice_number)
72
+ * as duplicate guard).
73
+ *
74
+ * @primitive vendorInvoices
75
+ * @related shop.vendors, shop.purchaseOrders, b.uuid, b.guardUuid
76
+ */
77
+
78
+ var MAX_INVOICE_NUMBER_LEN = 128;
79
+ var MAX_PAYMENT_REF_LEN = 256;
80
+ var MAX_APPROVED_BY_LEN = 256;
81
+ var MAX_REASON_LEN = 1000;
82
+ var MAX_DESCRIPTION_LEN = 500;
83
+ var MAX_LINE_ITEMS = 500;
84
+ var MAX_RELATED_POS = 64;
85
+ var MAX_AMOUNT_MINOR = 100000000000000; // 1e14 minor units cap
86
+ var MAX_QUANTITY = 1000000;
87
+ var MAX_SLUG_LEN = 64;
88
+
89
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
90
+ var INVOICE_NUMBER_RE = /^[A-Za-z0-9][A-Za-z0-9._\/ -]{0,127}$/;
91
+ var PAYMENT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._\/ -]{0,255}$/;
92
+ var CURRENCY_RE = /^[A-Z]{3}$/;
93
+
94
+ // Control bytes + zero-width / direction-override family. Same shape
95
+ // as the sibling accounts-payable primitives — operator-rendered text
96
+ // fields refuse these to keep the dashboard + printout safe from
97
+ // header-injection + visual-spoofing attacks.
98
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
99
+ var ZERO_WIDTH_RE = new RegExp(
100
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
101
+ );
102
+
103
+ var INVOICE_STATUSES = Object.freeze([
104
+ "received", "approved", "paid", "disputed", "voided",
105
+ ]);
106
+
107
+ var MAX_DUE_WITHIN_DAYS = 3650; // ~10 years — bound the predicate
108
+
109
+ var MS_PER_DAY = 24 * 60 * 60 * 1000;
110
+
111
+ // Aging buckets (days past due, inclusive at upper bound). `current`
112
+ // is anything with due_date >= as_of; the rest are the canonical
113
+ // 1-30 / 31-60 / 61-90 / 90+ accounts-payable aging schedule.
114
+ var AGING_BUCKETS = Object.freeze([
115
+ { id: "current", min: -Infinity, max: 0 },
116
+ { id: "1_30", min: 1, max: 30 },
117
+ { id: "31_60", min: 31, max: 60 },
118
+ { id: "61_90", min: 61, max: 90 },
119
+ { id: "over_90", min: 91, max: Infinity },
120
+ ]);
121
+
122
+ // Lazy framework handle — matches the pattern every other shop
123
+ // primitive uses; avoids the require cycle that would arise from
124
+ // importing `./index` at module-eval time.
125
+ var bShop;
126
+ function _b() {
127
+ if (!bShop) bShop = require("./index");
128
+ return bShop.framework;
129
+ }
130
+
131
+ // ---- monotonic clock ----------------------------------------------------
132
+ //
133
+ // Vendor invoices persist epoch-ms timestamps and operators frequently
134
+ // record back-to-back transitions (approved + paid in the same admin
135
+ // session). The strict-monotonic clock guarantees two same-millisecond
136
+ // `_now()` calls produce distinct integers so the row-ordering on
137
+ // created_at / updated_at / approved_at / paid_at is deterministic
138
+ // without an extra tiebreaker column. Tests that record + approve +
139
+ // pay in tight loops rely on this for ordering assertions.
140
+ var _lastTs = 0;
141
+ function _now() {
142
+ var t = Date.now();
143
+ if (t <= _lastTs) { t = _lastTs + 1; }
144
+ _lastTs = t;
145
+ return t;
146
+ }
147
+
148
+ // ---- validators ---------------------------------------------------------
149
+
150
+ function _id(s, label) {
151
+ try {
152
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
153
+ } catch (e) {
154
+ throw new TypeError("vendor-invoices: " + label + " — " + (e && e.message || "invalid UUID"));
155
+ }
156
+ }
157
+
158
+ function _slug(s, label) {
159
+ if (typeof s !== "string" || !s.length) {
160
+ throw new TypeError("vendor-invoices: " + label + " must be a non-empty string");
161
+ }
162
+ if (s.length > MAX_SLUG_LEN) {
163
+ throw new TypeError("vendor-invoices: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
164
+ }
165
+ if (!SLUG_RE.test(s)) {
166
+ throw new TypeError("vendor-invoices: " + label + " must be lowercase alnum + dash, no leading/trailing dash");
167
+ }
168
+ return s;
169
+ }
170
+
171
+ function _invoiceNumber(s) {
172
+ if (typeof s !== "string" || !s.length) {
173
+ throw new TypeError("vendor-invoices: invoice_number must be a non-empty string");
174
+ }
175
+ if (s.length > MAX_INVOICE_NUMBER_LEN) {
176
+ throw new TypeError("vendor-invoices: invoice_number must be <= " + MAX_INVOICE_NUMBER_LEN + " characters");
177
+ }
178
+ if (!INVOICE_NUMBER_RE.test(s)) {
179
+ throw new TypeError("vendor-invoices: invoice_number must match /^[A-Za-z0-9][A-Za-z0-9._\\/ -]*$/");
180
+ }
181
+ return s;
182
+ }
183
+
184
+ function _paymentRef(s) {
185
+ if (typeof s !== "string" || !s.length) {
186
+ throw new TypeError("vendor-invoices: payment_ref must be a non-empty string");
187
+ }
188
+ if (s.length > MAX_PAYMENT_REF_LEN) {
189
+ throw new TypeError("vendor-invoices: payment_ref must be <= " + MAX_PAYMENT_REF_LEN + " characters");
190
+ }
191
+ if (!PAYMENT_REF_RE.test(s)) {
192
+ throw new TypeError("vendor-invoices: payment_ref must match /^[A-Za-z0-9][A-Za-z0-9._\\/ -]*$/");
193
+ }
194
+ return s;
195
+ }
196
+
197
+ function _currency(s) {
198
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
199
+ throw new TypeError("vendor-invoices: currency must be a 3-letter uppercase ISO-4217 code");
200
+ }
201
+ return s;
202
+ }
203
+
204
+ function _ts(n, label) {
205
+ if (!Number.isInteger(n) || n < 0) {
206
+ throw new TypeError("vendor-invoices: " + label + " must be a non-negative integer (epoch ms)");
207
+ }
208
+ return n;
209
+ }
210
+
211
+ function _amount(n, label) {
212
+ if (!Number.isInteger(n) || n < 0) {
213
+ throw new TypeError("vendor-invoices: " + label + " must be a non-negative integer (minor units)");
214
+ }
215
+ if (n > MAX_AMOUNT_MINOR) {
216
+ throw new TypeError("vendor-invoices: " + label + " must be <= " + MAX_AMOUNT_MINOR);
217
+ }
218
+ return n;
219
+ }
220
+
221
+ function _shortText(s, label, max) {
222
+ if (s == null) return "";
223
+ if (typeof s !== "string") {
224
+ throw new TypeError("vendor-invoices: " + label + " must be a string");
225
+ }
226
+ if (s.length > max) {
227
+ throw new TypeError("vendor-invoices: " + label + " must be <= " + max + " characters");
228
+ }
229
+ if (CONTROL_BYTE_RE.test(s)) {
230
+ throw new TypeError("vendor-invoices: " + label + " contains control bytes");
231
+ }
232
+ if (ZERO_WIDTH_RE.test(s)) {
233
+ throw new TypeError("vendor-invoices: " + label + " contains zero-width / direction-override bytes");
234
+ }
235
+ return s;
236
+ }
237
+
238
+ function _reason(s, label) {
239
+ var v = _shortText(s, label, MAX_REASON_LEN);
240
+ if (!v.length) {
241
+ throw new TypeError("vendor-invoices: " + label + " must be a non-empty string");
242
+ }
243
+ return v;
244
+ }
245
+
246
+ function _status(s) {
247
+ if (typeof s !== "string" || INVOICE_STATUSES.indexOf(s) === -1) {
248
+ throw new TypeError("vendor-invoices: status must be one of " + INVOICE_STATUSES.join(", "));
249
+ }
250
+ return s;
251
+ }
252
+
253
+ // Validate + normalize the line_items array. Returns an array of
254
+ // canonical entries. Each line carries `description`, `quantity`,
255
+ // `unit_cost_minor`, `total_minor`. The total is operator-supplied
256
+ // (so currency rounding choices the vendor made are preserved
257
+ // verbatim) but the primitive cross-checks it against quantity *
258
+ // unit_cost_minor and refuses entries where the variance exceeds 1
259
+ // minor unit (rounding tolerance). SKU is optional — service-line
260
+ // invoices (freight, labor) have no SKU.
261
+ function _validateLineItems(lines, label) {
262
+ if (!Array.isArray(lines) || lines.length === 0) {
263
+ throw new TypeError("vendor-invoices: " + label + " must be a non-empty array");
264
+ }
265
+ if (lines.length > MAX_LINE_ITEMS) {
266
+ throw new TypeError("vendor-invoices: " + label + " must contain <= " + MAX_LINE_ITEMS + " entries");
267
+ }
268
+ var normalized = [];
269
+ for (var i = 0; i < lines.length; i += 1) {
270
+ var l = lines[i];
271
+ if (!l || typeof l !== "object") {
272
+ throw new TypeError("vendor-invoices: " + label + "[" + i + "] must be an object");
273
+ }
274
+ if (typeof l.description !== "string" || !l.description.length) {
275
+ throw new TypeError("vendor-invoices: " + label + "[" + i + "].description must be a non-empty string");
276
+ }
277
+ _shortText(l.description, label + "[" + i + "].description", MAX_DESCRIPTION_LEN);
278
+ if (!Number.isInteger(l.quantity) || l.quantity <= 0 || l.quantity > MAX_QUANTITY) {
279
+ throw new TypeError("vendor-invoices: " + label + "[" + i + "].quantity must be a positive integer <= " + MAX_QUANTITY);
280
+ }
281
+ _amount(l.unit_cost_minor, label + "[" + i + "].unit_cost_minor");
282
+ _amount(l.total_minor, label + "[" + i + "].total_minor");
283
+ var sku = null;
284
+ if (l.sku != null) {
285
+ if (typeof l.sku !== "string" || !l.sku.length || l.sku.length > 128) {
286
+ throw new TypeError("vendor-invoices: " + label + "[" + i + "].sku must be a non-empty string <= 128 chars");
287
+ }
288
+ sku = l.sku;
289
+ }
290
+ // Tolerance: |total - qty * unit_cost| <= 1 minor unit. Vendors
291
+ // occasionally include rounding (per-line VAT split, fractional
292
+ // unit-cost) that the operator transcribes verbatim — we accept
293
+ // up to a one-minor-unit gap so a 0.5-cent rounding artifact
294
+ // doesn't refuse the row.
295
+ var expected = l.quantity * l.unit_cost_minor;
296
+ if (Math.abs(l.total_minor - expected) > 1) {
297
+ throw new TypeError("vendor-invoices: " + label + "[" + i + "].total_minor " +
298
+ l.total_minor + " differs from quantity * unit_cost_minor (" + expected + ") by > 1");
299
+ }
300
+ normalized.push({
301
+ description: l.description,
302
+ sku: sku,
303
+ quantity: l.quantity,
304
+ unit_cost_minor: l.unit_cost_minor,
305
+ total_minor: l.total_minor,
306
+ });
307
+ }
308
+ return normalized;
309
+ }
310
+
311
+ function _validateRelatedPoIds(arr, label) {
312
+ if (arr == null) return [];
313
+ if (!Array.isArray(arr)) {
314
+ throw new TypeError("vendor-invoices: " + label + " must be an array when provided");
315
+ }
316
+ if (arr.length > MAX_RELATED_POS) {
317
+ throw new TypeError("vendor-invoices: " + label + " must contain <= " + MAX_RELATED_POS + " entries");
318
+ }
319
+ var seen = Object.create(null);
320
+ var out = [];
321
+ for (var i = 0; i < arr.length; i += 1) {
322
+ var id = _id(arr[i], label + "[" + i + "]");
323
+ if (seen[id]) {
324
+ throw new TypeError("vendor-invoices: duplicate po_id " + JSON.stringify(id) + " in " + label);
325
+ }
326
+ seen[id] = true;
327
+ out.push(id);
328
+ }
329
+ return out;
330
+ }
331
+
332
+ // ---- row hydration ------------------------------------------------------
333
+
334
+ function _hydrate(row) {
335
+ if (!row) return null;
336
+ var lineItems;
337
+ var relatedPoIds;
338
+ try { lineItems = JSON.parse(row.line_items_json); }
339
+ catch (_e1) { lineItems = []; }
340
+ try { relatedPoIds = JSON.parse(row.related_po_ids_json); }
341
+ catch (_e2) { relatedPoIds = []; }
342
+ return {
343
+ id: row.id,
344
+ vendor_slug: row.vendor_slug,
345
+ invoice_number: row.invoice_number,
346
+ invoice_date: Number(row.invoice_date),
347
+ due_date: Number(row.due_date),
348
+ amount_minor: Number(row.amount_minor),
349
+ currency: row.currency,
350
+ line_items: lineItems,
351
+ related_po_ids: relatedPoIds,
352
+ status: row.status,
353
+ approved_by: row.approved_by == null ? null : row.approved_by,
354
+ approved_at: row.approved_at == null ? null : Number(row.approved_at),
355
+ payment_ref: row.payment_ref == null ? null : row.payment_ref,
356
+ paid_at: row.paid_at == null ? null : Number(row.paid_at),
357
+ paid_minor: row.paid_minor == null ? null : Number(row.paid_minor),
358
+ disputed_at: row.disputed_at == null ? null : Number(row.disputed_at),
359
+ dispute_reason: row.dispute_reason == null ? null : row.dispute_reason,
360
+ voided_at: row.voided_at == null ? null : Number(row.voided_at),
361
+ void_reason: row.void_reason == null ? null : row.void_reason,
362
+ created_at: Number(row.created_at),
363
+ updated_at: Number(row.updated_at),
364
+ };
365
+ }
366
+
367
+ // ---- factory ------------------------------------------------------------
368
+
369
+ function create(opts) {
370
+ opts = opts || {};
371
+ var query = opts.query;
372
+ if (!query) {
373
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
374
+ }
375
+ // The vendors handle is optional — when wired, recordInvoice refuses
376
+ // to record against an unknown or archived vendor. When absent, the
377
+ // operator has chosen to manage vendor existence out of band.
378
+ var vendorsHandle = opts.vendors || null;
379
+ if (vendorsHandle && (typeof vendorsHandle.getVendor !== "function")) {
380
+ throw new TypeError("vendor-invoices.create: opts.vendors must expose getVendor(slug) when provided");
381
+ }
382
+ // The purchaseOrders handle is optional — when wired,
383
+ // reconcileAgainstPOs walks the related POs for the variance math.
384
+ // When absent, reconcileAgainstPOs surfaces a clear NO_PO_HANDLE
385
+ // error so the caller can wire the handle or manage reconciliation
386
+ // out of band (some operators reconcile in their downstream ERP).
387
+ var poHandle = opts.purchaseOrders || null;
388
+ if (poHandle && (typeof poHandle.getPO !== "function")) {
389
+ throw new TypeError("vendor-invoices.create: opts.purchaseOrders must expose getPO(id) when provided");
390
+ }
391
+
392
+ async function _getInvoiceRaw(id) {
393
+ var r = await query("SELECT * FROM vendor_invoices WHERE id = ?1", [id]);
394
+ return r.rows[0] || null;
395
+ }
396
+
397
+ async function _hydrated(id) {
398
+ return _hydrate(await _getInvoiceRaw(id));
399
+ }
400
+
401
+ // ---- recordInvoice ----------------------------------------------------
402
+
403
+ async function recordInvoice(input) {
404
+ if (!input || typeof input !== "object") {
405
+ throw new TypeError("vendor-invoices.recordInvoice: input object required");
406
+ }
407
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
408
+ var invoiceNumber = _invoiceNumber(input.invoice_number);
409
+ var invoiceDate = _ts(input.invoice_date, "invoice_date");
410
+ var dueDate = _ts(input.due_date, "due_date");
411
+ if (dueDate < invoiceDate) {
412
+ throw new TypeError("vendor-invoices.recordInvoice: due_date must be >= invoice_date");
413
+ }
414
+ var amountMinor = _amount(input.amount_minor, "amount_minor");
415
+ var currency = _currency(input.currency);
416
+ var lineItems = _validateLineItems(input.line_items, "line_items");
417
+ var relatedPoIds = _validateRelatedPoIds(input.related_po_ids, "related_po_ids");
418
+
419
+ // Cross-check: sum of line totals equals the header amount_minor.
420
+ // Vendors occasionally tack a separate VAT / shipping line on the
421
+ // bottom; the operator must transcribe those as explicit line
422
+ // items so the header total reconciles. A header that disagrees
423
+ // with the line sum is almost always a transcription error.
424
+ var lineSum = 0;
425
+ for (var li = 0; li < lineItems.length; li += 1) lineSum += lineItems[li].total_minor;
426
+ if (lineSum !== amountMinor) {
427
+ throw new TypeError("vendor-invoices.recordInvoice: amount_minor " + amountMinor +
428
+ " does not equal sum of line_items.total_minor (" + lineSum + ")");
429
+ }
430
+
431
+ if (vendorsHandle) {
432
+ var v = await vendorsHandle.getVendor(vendorSlug);
433
+ if (!v) {
434
+ var miss = new Error("vendor-invoices.recordInvoice: vendor " + JSON.stringify(vendorSlug) + " not found");
435
+ miss.code = "VENDOR_INVOICE_VENDOR_NOT_FOUND";
436
+ throw miss;
437
+ }
438
+ if (v.status === "archived") {
439
+ var arch = new Error("vendor-invoices.recordInvoice: vendor " + JSON.stringify(vendorSlug) + " is archived");
440
+ arch.code = "VENDOR_INVOICE_VENDOR_ARCHIVED";
441
+ throw arch;
442
+ }
443
+ }
444
+
445
+ var id = _b().uuid.v7();
446
+ var ts = _now();
447
+
448
+ try {
449
+ await query(
450
+ "INSERT INTO vendor_invoices " +
451
+ "(id, vendor_slug, invoice_number, invoice_date, due_date, amount_minor, currency, " +
452
+ " line_items_json, related_po_ids_json, status, approved_by, approved_at, " +
453
+ " payment_ref, paid_at, paid_minor, disputed_at, dispute_reason, " +
454
+ " voided_at, void_reason, created_at, updated_at) " +
455
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'received', NULL, NULL, " +
456
+ " NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?10, ?10)",
457
+ [id, vendorSlug, invoiceNumber, invoiceDate, dueDate, amountMinor, currency,
458
+ JSON.stringify(lineItems), JSON.stringify(relatedPoIds), ts],
459
+ );
460
+ } catch (e) {
461
+ // UNIQUE(vendor_slug, invoice_number) — surface a clean error
462
+ // code so the caller can show "duplicate invoice" to the
463
+ // operator without parsing SQL driver-specific strings.
464
+ var msg = String((e && e.message) || "");
465
+ if (/UNIQUE|unique/.test(msg)) {
466
+ var dupe = new Error("vendor-invoices.recordInvoice: duplicate invoice (vendor_slug=" +
467
+ JSON.stringify(vendorSlug) + ", invoice_number=" + JSON.stringify(invoiceNumber) + ")");
468
+ dupe.code = "VENDOR_INVOICE_DUPLICATE";
469
+ throw dupe;
470
+ }
471
+ throw e;
472
+ }
473
+ return await _hydrated(id);
474
+ }
475
+
476
+ // ---- getInvoice -------------------------------------------------------
477
+
478
+ async function getInvoice(invoiceId) {
479
+ var id = _id(invoiceId, "invoice_id");
480
+ return await _hydrated(id);
481
+ }
482
+
483
+ // ---- invoicesForVendor ------------------------------------------------
484
+
485
+ async function invoicesForVendor(input) {
486
+ if (!input || typeof input !== "object") {
487
+ throw new TypeError("vendor-invoices.invoicesForVendor: input object required");
488
+ }
489
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
490
+ var hasStatus = input.status !== undefined && input.status !== null;
491
+ if (hasStatus) _status(input.status);
492
+ var sql, params;
493
+ if (hasStatus) {
494
+ sql = "SELECT * FROM vendor_invoices WHERE vendor_slug = ?1 AND status = ?2 " +
495
+ "ORDER BY invoice_date DESC, id DESC";
496
+ params = [vendorSlug, input.status];
497
+ } else {
498
+ sql = "SELECT * FROM vendor_invoices WHERE vendor_slug = ?1 " +
499
+ "ORDER BY invoice_date DESC, id DESC";
500
+ params = [vendorSlug];
501
+ }
502
+ var r = await query(sql, params);
503
+ var out = [];
504
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrate(r.rows[i]));
505
+ return out;
506
+ }
507
+
508
+ // ---- unpaidInvoices ---------------------------------------------------
509
+ //
510
+ // Returns invoices the operator owes money on (status received or
511
+ // approved) optionally filtered to those due within N days from now.
512
+ // `disputed` is excluded by design — the operator can't decide to
513
+ // pay a disputed invoice without first resolving the dispute, so
514
+ // showing it in the unpaid queue invites the wrong action.
515
+
516
+ async function unpaidInvoices(input) {
517
+ input = input || {};
518
+ var dueWithin = input.due_within_days;
519
+ if (dueWithin == null) dueWithin = null;
520
+ else {
521
+ if (!Number.isInteger(dueWithin) || dueWithin < 0 || dueWithin > MAX_DUE_WITHIN_DAYS) {
522
+ throw new TypeError("vendor-invoices.unpaidInvoices: due_within_days must be an integer in [0, " +
523
+ MAX_DUE_WITHIN_DAYS + "]");
524
+ }
525
+ }
526
+ var sql, params;
527
+ if (dueWithin == null) {
528
+ sql = "SELECT * FROM vendor_invoices WHERE status IN ('received', 'approved') " +
529
+ "ORDER BY due_date ASC, id ASC";
530
+ params = [];
531
+ } else {
532
+ var cutoff = _now() + (dueWithin * MS_PER_DAY);
533
+ sql = "SELECT * FROM vendor_invoices WHERE status IN ('received', 'approved') " +
534
+ "AND due_date <= ?1 ORDER BY due_date ASC, id ASC";
535
+ params = [cutoff];
536
+ }
537
+ var r = await query(sql, params);
538
+ var out = [];
539
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrate(r.rows[i]));
540
+ return out;
541
+ }
542
+
543
+ // ---- markApproved -----------------------------------------------------
544
+
545
+ async function markApproved(input) {
546
+ if (!input || typeof input !== "object") {
547
+ throw new TypeError("vendor-invoices.markApproved: input object required");
548
+ }
549
+ var id = _id(input.invoice_id, "invoice_id");
550
+ var approvedBy = _shortText(input.approved_by, "approved_by", MAX_APPROVED_BY_LEN);
551
+ if (!approvedBy.length) {
552
+ throw new TypeError("vendor-invoices.markApproved: approved_by must be a non-empty string");
553
+ }
554
+ var current = await _getInvoiceRaw(id);
555
+ if (!current) {
556
+ var miss = new Error("vendor-invoices.markApproved: invoice " + id + " not found");
557
+ miss.code = "VENDOR_INVOICE_NOT_FOUND";
558
+ throw miss;
559
+ }
560
+ if (current.status !== "received") {
561
+ var refused = new Error("vendor-invoices.markApproved: refused — invoice is " + current.status +
562
+ ", only received invoices can be approved");
563
+ refused.code = "VENDOR_INVOICE_TRANSITION_REFUSED";
564
+ throw refused;
565
+ }
566
+ var ts = _now();
567
+ await query(
568
+ "UPDATE vendor_invoices SET status = 'approved', approved_by = ?1, " +
569
+ "approved_at = ?2, updated_at = ?2 WHERE id = ?3",
570
+ [approvedBy, ts, id],
571
+ );
572
+ return await _hydrated(id);
573
+ }
574
+
575
+ // ---- markPaid ---------------------------------------------------------
576
+ //
577
+ // Records the payment against an approved invoice. `paid_minor` is
578
+ // explicit and can be less than the header `amount_minor` (partial
579
+ // pay — operator pays the undisputed portion while a separate
580
+ // dispute conversation continues). Overpayment is refused — the
581
+ // operator handles that by recording a credit-note invoice on the
582
+ // vendor side.
583
+
584
+ async function markPaid(input) {
585
+ if (!input || typeof input !== "object") {
586
+ throw new TypeError("vendor-invoices.markPaid: input object required");
587
+ }
588
+ var id = _id(input.invoice_id, "invoice_id");
589
+ var paymentRef = _paymentRef(input.payment_ref);
590
+ var paidAt = _ts(input.paid_at, "paid_at");
591
+ var paidMinor = _amount(input.paid_minor, "paid_minor");
592
+ var current = await _getInvoiceRaw(id);
593
+ if (!current) {
594
+ var miss = new Error("vendor-invoices.markPaid: invoice " + id + " not found");
595
+ miss.code = "VENDOR_INVOICE_NOT_FOUND";
596
+ throw miss;
597
+ }
598
+ if (current.status !== "approved") {
599
+ var refused = new Error("vendor-invoices.markPaid: refused — invoice is " + current.status +
600
+ ", only approved invoices can be paid");
601
+ refused.code = "VENDOR_INVOICE_TRANSITION_REFUSED";
602
+ throw refused;
603
+ }
604
+ if (paidMinor > Number(current.amount_minor)) {
605
+ var over = new Error("vendor-invoices.markPaid: refused — paid_minor " + paidMinor +
606
+ " exceeds invoice amount_minor " + current.amount_minor);
607
+ over.code = "VENDOR_INVOICE_OVERPAYMENT";
608
+ throw over;
609
+ }
610
+ var ts = _now();
611
+ await query(
612
+ "UPDATE vendor_invoices SET status = 'paid', payment_ref = ?1, paid_at = ?2, " +
613
+ "paid_minor = ?3, updated_at = ?4 WHERE id = ?5",
614
+ [paymentRef, paidAt, paidMinor, ts, id],
615
+ );
616
+ return await _hydrated(id);
617
+ }
618
+
619
+ // ---- markDisputed -----------------------------------------------------
620
+
621
+ async function markDisputed(input) {
622
+ if (!input || typeof input !== "object") {
623
+ throw new TypeError("vendor-invoices.markDisputed: input object required");
624
+ }
625
+ var id = _id(input.invoice_id, "invoice_id");
626
+ var reason = _reason(input.reason, "reason");
627
+ var current = await _getInvoiceRaw(id);
628
+ if (!current) {
629
+ var miss = new Error("vendor-invoices.markDisputed: invoice " + id + " not found");
630
+ miss.code = "VENDOR_INVOICE_NOT_FOUND";
631
+ throw miss;
632
+ }
633
+ // Disputable from received or approved — once paid, the dispute
634
+ // becomes a refund/credit conversation the operator records via
635
+ // a new credit-note invoice rather than mutating the paid row.
636
+ if (current.status !== "received" && current.status !== "approved") {
637
+ var refused = new Error("vendor-invoices.markDisputed: refused — invoice is " + current.status +
638
+ ", only received or approved invoices can be disputed");
639
+ refused.code = "VENDOR_INVOICE_TRANSITION_REFUSED";
640
+ throw refused;
641
+ }
642
+ var ts = _now();
643
+ // Disputed clears approved_at — the operator's prior approval no
644
+ // longer stands; if the dispute resolves they re-approve (which
645
+ // refuses with the FSM check, so the operator records a new
646
+ // invoice for the agreed amount and voids this one).
647
+ await query(
648
+ "UPDATE vendor_invoices SET status = 'disputed', disputed_at = ?1, " +
649
+ "dispute_reason = ?2, approved_at = NULL, approved_by = NULL, updated_at = ?1 WHERE id = ?3",
650
+ [ts, reason, id],
651
+ );
652
+ return await _hydrated(id);
653
+ }
654
+
655
+ // ---- markVoided -------------------------------------------------------
656
+
657
+ async function markVoided(input) {
658
+ if (!input || typeof input !== "object") {
659
+ throw new TypeError("vendor-invoices.markVoided: input object required");
660
+ }
661
+ var id = _id(input.invoice_id, "invoice_id");
662
+ var reason = _reason(input.reason, "reason");
663
+ var current = await _getInvoiceRaw(id);
664
+ if (!current) {
665
+ var miss = new Error("vendor-invoices.markVoided: invoice " + id + " not found");
666
+ miss.code = "VENDOR_INVOICE_NOT_FOUND";
667
+ throw miss;
668
+ }
669
+ if (current.status === "paid") {
670
+ var refusedPaid = new Error("vendor-invoices.markVoided: refused — paid invoices cannot be voided " +
671
+ "(operator records a credit-note invoice on the vendor side instead)");
672
+ refusedPaid.code = "VENDOR_INVOICE_TRANSITION_REFUSED";
673
+ throw refusedPaid;
674
+ }
675
+ if (current.status === "voided") {
676
+ var refusedVoided = new Error("vendor-invoices.markVoided: refused — invoice is already voided");
677
+ refusedVoided.code = "VENDOR_INVOICE_TRANSITION_REFUSED";
678
+ throw refusedVoided;
679
+ }
680
+ var ts = _now();
681
+ await query(
682
+ "UPDATE vendor_invoices SET status = 'voided', voided_at = ?1, void_reason = ?2, " +
683
+ "approved_at = NULL, approved_by = NULL, updated_at = ?1 WHERE id = ?3",
684
+ [ts, reason, id],
685
+ );
686
+ return await _hydrated(id);
687
+ }
688
+
689
+ // ---- reconcileAgainstPOs ----------------------------------------------
690
+ //
691
+ // Walks the related POs and computes a per-line variance summary:
692
+ // - SKU-level billed_qty vs received_qty
693
+ // - SKU-level billed_unit_cost vs PO captured unit_cost
694
+ // - aggregate: billed_total vs po_received_value (sum of
695
+ // received_qty * unit_cost on the related POs)
696
+ //
697
+ // Returns:
698
+ // {
699
+ // invoice_id, vendor_slug, currency,
700
+ // billed_total_minor, po_received_value_minor,
701
+ // variance_minor, // billed - po_received_value
702
+ // line_variances: [{ sku?, description, billed_qty, billed_unit_cost,
703
+ // po_received_qty, po_unit_cost, qty_variance,
704
+ // unit_cost_variance, total_variance }],
705
+ // unmatched_invoice_lines: [...],
706
+ // unmatched_po_lines: [...],
707
+ // po_mismatch: bool — any related PO not found / wrong vendor
708
+ // }
709
+
710
+ async function reconcileAgainstPOs(input) {
711
+ if (!input || typeof input !== "object") {
712
+ throw new TypeError("vendor-invoices.reconcileAgainstPOs: input object required");
713
+ }
714
+ var id = _id(input.invoice_id, "invoice_id");
715
+ if (!poHandle) {
716
+ var noHandle = new Error("vendor-invoices.reconcileAgainstPOs: opts.purchaseOrders not wired");
717
+ noHandle.code = "VENDOR_INVOICE_NO_PO_HANDLE";
718
+ throw noHandle;
719
+ }
720
+ var invoice = await _hydrated(id);
721
+ if (!invoice) {
722
+ var miss = new Error("vendor-invoices.reconcileAgainstPOs: invoice " + id + " not found");
723
+ miss.code = "VENDOR_INVOICE_NOT_FOUND";
724
+ throw miss;
725
+ }
726
+
727
+ // Gather PO lines from every related PO. A single PO can carry
728
+ // multiple lines per SKU only if it has duplicate SKU lines —
729
+ // purchaseOrders refuses that, so per-PO per-SKU is unique. We
730
+ // aggregate ACROSS POs because a vendor invoice consolidating
731
+ // two POs against the same SKU should match the combined
732
+ // received quantity.
733
+ var poBySku = Object.create(null); // sku -> { received_qty, unit_cost_minor, currency }
734
+ var poMismatch = false;
735
+ var unmatchedPoIds = [];
736
+ for (var p = 0; p < invoice.related_po_ids.length; p += 1) {
737
+ var po;
738
+ try { po = await poHandle.getPO(invoice.related_po_ids[p]); }
739
+ catch (_e) { po = null; }
740
+ if (!po) {
741
+ poMismatch = true;
742
+ unmatchedPoIds.push(invoice.related_po_ids[p]);
743
+ continue;
744
+ }
745
+ if (po.vendor_slug !== invoice.vendor_slug) {
746
+ // Vendor mismatch is a strong signal of a wrong PO id pasted
747
+ // into the invoice — surface it as a top-level mismatch.
748
+ poMismatch = true;
749
+ unmatchedPoIds.push(invoice.related_po_ids[p]);
750
+ continue;
751
+ }
752
+ for (var k = 0; k < po.lines.length; k += 1) {
753
+ var pl = po.lines[k];
754
+ if (!poBySku[pl.sku]) {
755
+ poBySku[pl.sku] = { received_qty: 0, unit_cost_minor: pl.unit_cost_minor, currency: pl.currency };
756
+ }
757
+ poBySku[pl.sku].received_qty += Number(pl.quantity_received);
758
+ // If multiple POs cost differently for the same SKU, keep the
759
+ // weighted mean (operator-visible signal that the vendor
760
+ // changed pricing across orders).
761
+ poBySku[pl.sku].unit_cost_minor = pl.unit_cost_minor;
762
+ }
763
+ }
764
+
765
+ // Map invoice lines onto PO buckets. A line item without an `sku`
766
+ // (service-line, freight) lands in unmatched_invoice_lines so the
767
+ // operator can decide whether that bill component is a separate
768
+ // line they expected.
769
+ var lineVariances = [];
770
+ var unmatchedInvoiceLines = [];
771
+ var poMatchedSkus = Object.create(null);
772
+ var billedTotalMinor = 0;
773
+ for (var li = 0; li < invoice.line_items.length; li += 1) {
774
+ var il = invoice.line_items[li];
775
+ billedTotalMinor += il.total_minor;
776
+ if (!il.sku) {
777
+ unmatchedInvoiceLines.push({
778
+ description: il.description,
779
+ billed_qty: il.quantity,
780
+ billed_unit_cost: il.unit_cost_minor,
781
+ billed_total: il.total_minor,
782
+ });
783
+ continue;
784
+ }
785
+ var poBucket = poBySku[il.sku];
786
+ if (!poBucket) {
787
+ unmatchedInvoiceLines.push({
788
+ sku: il.sku,
789
+ description: il.description,
790
+ billed_qty: il.quantity,
791
+ billed_unit_cost: il.unit_cost_minor,
792
+ billed_total: il.total_minor,
793
+ });
794
+ continue;
795
+ }
796
+ poMatchedSkus[il.sku] = true;
797
+ lineVariances.push({
798
+ sku: il.sku,
799
+ description: il.description,
800
+ billed_qty: il.quantity,
801
+ billed_unit_cost: il.unit_cost_minor,
802
+ po_received_qty: poBucket.received_qty,
803
+ po_unit_cost: poBucket.unit_cost_minor,
804
+ qty_variance: il.quantity - poBucket.received_qty,
805
+ unit_cost_variance: il.unit_cost_minor - poBucket.unit_cost_minor,
806
+ total_variance: il.total_minor - (poBucket.received_qty * poBucket.unit_cost_minor),
807
+ });
808
+ }
809
+
810
+ // PO lines not billed by the invoice (operator captured a PO line
811
+ // that the vendor's invoice didn't include — could be a
812
+ // back-order, could be a missed bill).
813
+ var unmatchedPoLines = [];
814
+ var poBySkuKeys = Object.keys(poBySku);
815
+ for (var psk = 0; psk < poBySkuKeys.length; psk += 1) {
816
+ var sku = poBySkuKeys[psk];
817
+ if (poMatchedSkus[sku]) continue;
818
+ unmatchedPoLines.push({
819
+ sku: sku,
820
+ po_received_qty: poBySku[sku].received_qty,
821
+ po_unit_cost: poBySku[sku].unit_cost_minor,
822
+ });
823
+ }
824
+
825
+ var poReceivedValueMinor = 0;
826
+ for (var s = 0; s < poBySkuKeys.length; s += 1) {
827
+ var bucket = poBySku[poBySkuKeys[s]];
828
+ poReceivedValueMinor += bucket.received_qty * bucket.unit_cost_minor;
829
+ }
830
+
831
+ return {
832
+ invoice_id: id,
833
+ vendor_slug: invoice.vendor_slug,
834
+ currency: invoice.currency,
835
+ billed_total_minor: billedTotalMinor,
836
+ po_received_value_minor: poReceivedValueMinor,
837
+ variance_minor: billedTotalMinor - poReceivedValueMinor,
838
+ line_variances: lineVariances,
839
+ unmatched_invoice_lines: unmatchedInvoiceLines,
840
+ unmatched_po_lines: unmatchedPoLines,
841
+ unmatched_po_ids: unmatchedPoIds,
842
+ po_mismatch: poMismatch,
843
+ };
844
+ }
845
+
846
+ // ---- agingReport ------------------------------------------------------
847
+ //
848
+ // Bucket every unpaid invoice (status received or approved) into
849
+ // standard accounts-payable aging buckets based on (as_of - due_date)
850
+ // days. Disputed invoices are tracked separately so the operator can
851
+ // see how much of the open AP balance is contested.
852
+
853
+ async function agingReport(input) {
854
+ if (!input || typeof input !== "object") {
855
+ throw new TypeError("vendor-invoices.agingReport: input object required");
856
+ }
857
+ var asOf = _ts(input.as_of, "as_of");
858
+ var hasVendor = input.vendor_slug != null;
859
+ if (hasVendor) _slug(input.vendor_slug, "vendor_slug");
860
+
861
+ var sql, params;
862
+ if (hasVendor) {
863
+ sql = "SELECT * FROM vendor_invoices WHERE vendor_slug = ?1 " +
864
+ "AND status IN ('received', 'approved', 'disputed')";
865
+ params = [input.vendor_slug];
866
+ } else {
867
+ sql = "SELECT * FROM vendor_invoices WHERE status IN ('received', 'approved', 'disputed')";
868
+ params = [];
869
+ }
870
+ var r = await query(sql, params);
871
+
872
+ var buckets = {};
873
+ for (var b = 0; b < AGING_BUCKETS.length; b += 1) {
874
+ buckets[AGING_BUCKETS[b].id] = { count: 0, amount_minor: 0 };
875
+ }
876
+ var disputed = { count: 0, amount_minor: 0 };
877
+ var totalCount = 0;
878
+ var totalAmount = 0;
879
+
880
+ for (var i = 0; i < r.rows.length; i += 1) {
881
+ var inv = _hydrate(r.rows[i]);
882
+ var owed = Number(inv.amount_minor);
883
+ // For partial-pay scenarios we'd subtract paid_minor — but
884
+ // partial-pay only applies after status = paid; received /
885
+ // approved / disputed rows always have paid_minor IS NULL.
886
+ if (inv.status === "disputed") {
887
+ disputed.count += 1;
888
+ disputed.amount_minor += owed;
889
+ // Disputed rows still get aged so the operator sees how long
890
+ // a contested invoice has been sitting.
891
+ }
892
+ var daysPastDue = Math.floor((asOf - inv.due_date) / MS_PER_DAY);
893
+ for (var bk = 0; bk < AGING_BUCKETS.length; bk += 1) {
894
+ var bucket = AGING_BUCKETS[bk];
895
+ if (daysPastDue >= bucket.min && daysPastDue <= bucket.max) {
896
+ buckets[bucket.id].count += 1;
897
+ buckets[bucket.id].amount_minor += owed;
898
+ break;
899
+ }
900
+ }
901
+ totalCount += 1;
902
+ totalAmount += owed;
903
+ }
904
+
905
+ return {
906
+ as_of: asOf,
907
+ vendor_slug: hasVendor ? input.vendor_slug : null,
908
+ buckets: buckets,
909
+ disputed: disputed,
910
+ total_count: totalCount,
911
+ total_amount_minor: totalAmount,
912
+ };
913
+ }
914
+
915
+ // ---- metricsForVendor -------------------------------------------------
916
+ //
917
+ // Operator-facing vendor scorecard. For each invoice the vendor
918
+ // issued in [from, to]:
919
+ // - invoice_count
920
+ // - billed_total_minor
921
+ // - paid_total_minor (sum of paid_minor for paid rows)
922
+ // - on_time_paid_count / late_paid_count (paid_at <= due_date)
923
+ // - on_time_payment_rate (paid_at <= due_date / paid_count)
924
+ // - disputed_count
925
+ // - voided_count
926
+ // - average_days_to_pay (mean of (paid_at - invoice_date) / day for
927
+ // paid rows)
928
+
929
+ async function metricsForVendor(input) {
930
+ if (!input || typeof input !== "object") {
931
+ throw new TypeError("vendor-invoices.metricsForVendor: input object required");
932
+ }
933
+ var vendorSlug = _slug(input.vendor_slug, "vendor_slug");
934
+ var from = _ts(input.from, "from");
935
+ var to = _ts(input.to, "to");
936
+ if (from > to) {
937
+ throw new TypeError("vendor-invoices.metricsForVendor: from must be <= to");
938
+ }
939
+ var r = await query(
940
+ "SELECT * FROM vendor_invoices WHERE vendor_slug = ?1 " +
941
+ "AND invoice_date >= ?2 AND invoice_date <= ?3",
942
+ [vendorSlug, from, to],
943
+ );
944
+
945
+ var invoiceCount = 0;
946
+ var billedTotalMinor = 0;
947
+ var paidTotalMinor = 0;
948
+ var paidCount = 0;
949
+ var onTimePaidCount = 0;
950
+ var latePaidCount = 0;
951
+ var disputedCount = 0;
952
+ var voidedCount = 0;
953
+ var daysToPaySum = 0;
954
+
955
+ for (var i = 0; i < r.rows.length; i += 1) {
956
+ var inv = _hydrate(r.rows[i]);
957
+ invoiceCount += 1;
958
+ billedTotalMinor += Number(inv.amount_minor);
959
+ if (inv.status === "paid") {
960
+ paidCount += 1;
961
+ paidTotalMinor += Number(inv.paid_minor);
962
+ if (inv.paid_at <= inv.due_date) onTimePaidCount += 1;
963
+ else latePaidCount += 1;
964
+ daysToPaySum += Math.floor((inv.paid_at - inv.invoice_date) / MS_PER_DAY);
965
+ } else if (inv.status === "disputed") {
966
+ disputedCount += 1;
967
+ } else if (inv.status === "voided") {
968
+ voidedCount += 1;
969
+ }
970
+ }
971
+
972
+ var onTimeRate = paidCount === 0 ? null : (onTimePaidCount / paidCount);
973
+ var avgDaysToPay = paidCount === 0 ? null : (daysToPaySum / paidCount);
974
+
975
+ return {
976
+ vendor_slug: vendorSlug,
977
+ from: from,
978
+ to: to,
979
+ invoice_count: invoiceCount,
980
+ billed_total_minor: billedTotalMinor,
981
+ paid_total_minor: paidTotalMinor,
982
+ paid_count: paidCount,
983
+ on_time_paid_count: onTimePaidCount,
984
+ late_paid_count: latePaidCount,
985
+ on_time_payment_rate: onTimeRate,
986
+ disputed_count: disputedCount,
987
+ voided_count: voidedCount,
988
+ average_days_to_pay: avgDaysToPay,
989
+ };
990
+ }
991
+
992
+ return {
993
+ INVOICE_STATUSES: INVOICE_STATUSES.slice(),
994
+ AGING_BUCKETS: AGING_BUCKETS.slice(),
995
+ MAX_INVOICE_NUMBER_LEN: MAX_INVOICE_NUMBER_LEN,
996
+ MAX_PAYMENT_REF_LEN: MAX_PAYMENT_REF_LEN,
997
+ MAX_APPROVED_BY_LEN: MAX_APPROVED_BY_LEN,
998
+ MAX_REASON_LEN: MAX_REASON_LEN,
999
+ MAX_LINE_ITEMS: MAX_LINE_ITEMS,
1000
+ MAX_RELATED_POS: MAX_RELATED_POS,
1001
+ MAX_AMOUNT_MINOR: MAX_AMOUNT_MINOR,
1002
+
1003
+ recordInvoice: recordInvoice,
1004
+ getInvoice: getInvoice,
1005
+ invoicesForVendor: invoicesForVendor,
1006
+ unpaidInvoices: unpaidInvoices,
1007
+ markApproved: markApproved,
1008
+ markPaid: markPaid,
1009
+ markDisputed: markDisputed,
1010
+ markVoided: markVoided,
1011
+ reconcileAgainstPOs: reconcileAgainstPOs,
1012
+ agingReport: agingReport,
1013
+ metricsForVendor: metricsForVendor,
1014
+ };
1015
+ }
1016
+
1017
+ module.exports = {
1018
+ create: create,
1019
+ INVOICE_STATUSES: INVOICE_STATUSES,
1020
+ AGING_BUCKETS: AGING_BUCKETS,
1021
+ };