@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.
- package/CHANGELOG.md +8 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|