@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,636 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventoryWriteoffs
4
+ * @title Inventory write-offs — operator-recorded stock removal for
5
+ * damage / loss / shrinkage / expiry / recall / sample / QC /
6
+ * theft
7
+ *
8
+ * @intro
9
+ * The shelf decrements every time a unit leaves the building. Most
10
+ * exits go through a sale (the checkout primitive composes
11
+ * inventoryLocations.adjustStock with the order id as the audit
12
+ * reason). The remaining exits — broken-on-receipt damage, expired
13
+ * yogurt, samples handed to an influencer, the pallet that walked
14
+ * off the loading dock — need an operator-attested record so the
15
+ * shrinkage report has a paper trail and the COGS attribution
16
+ * doesn't silently leak into a generic "adjustment".
17
+ *
18
+ * This primitive is the verb for that record. One row per
19
+ * write-off, eight enumerated reasons, optional location-scope, and
20
+ * two composition seams: inventoryLocations (always wired — the
21
+ * primitive refuses without it because every write-off MUST debit
22
+ * the shelf) and costLayers (optional — wired by operators running
23
+ * COGS reporting so the cost-impact column carries a real number).
24
+ *
25
+ * Surface:
26
+ *
27
+ * recordWriteoff({ sku, location_code?, quantity, reason, actor,
28
+ * notes?, occurred_at? })
29
+ * Validates input + checks the reason is in the enumerated set
30
+ * above. Debits stock via `inventoryLocations.adjustStock(-qty)`
31
+ * (refuses upstream if the shelf doesn't have enough). When
32
+ * costLayers is wired, also calls `costLayers.consumeForSale`
33
+ * with a synthetic order_id (`writeoff:<id>`) + line_id (`1`)
34
+ * so the destroyed stock attributes against existing cost
35
+ * layers — operators reading COGS-for-period reports see the
36
+ * shrinkage cost surface there too. The cost-impact value
37
+ * lands on the writeoff row so the dashboard joins one table
38
+ * to render the loss dollar figure. When costLayers refuses
39
+ * (no on-hand layers, currency drift), the inventory debit is
40
+ * reversed so the shelf doesn't disagree with the write-off
41
+ * record — operators see a clean refusal rather than a half-
42
+ * committed row.
43
+ *
44
+ * getWriteoff(id)
45
+ * Hydrated row or null on miss. guardUuid validates shape.
46
+ *
47
+ * listWriteoffs({ from?, to?, reason?, location_code?, limit?,
48
+ * cursor? })
49
+ * Paginated read. Defaults to occurred_at DESC ordering.
50
+ * Optional filters compose with AND. Cursor is HMAC-tagged so
51
+ * an operator can't tamper with it.
52
+ *
53
+ * costImpactForPeriod({ from, to, reason? })
54
+ * Sums `cost_impact_minor` across non-reversed rows whose
55
+ * occurred_at falls in `[from, to)`. Returns per-reason +
56
+ * grand-total. Refuses when the in-period rows span multiple
57
+ * currencies — the operator must reconcile (mixed-currency
58
+ * totals are meaningless without an FX conversion the
59
+ * primitive doesn't own).
60
+ *
61
+ * reverseWriteoff({ id, reason })
62
+ * Restores stock via `inventoryLocations.adjustStock(+qty)`.
63
+ * When costLayers was wired AND a cost-impact was recorded,
64
+ * composes `costLayers.recordReversal({ order_id, line_id })`
65
+ * so the cost layer pool gets the unit back at its original
66
+ * per-unit cost. Marks the row `status='reversed'` with the
67
+ * operator-supplied reason + a reversed_at timestamp. Refuses
68
+ * on already-reversed rows (no double-reverse).
69
+ *
70
+ * Composition:
71
+ * - b.uuid.v7 — writeoff PK (sortable UUID v7)
72
+ * - b.guardUuid — strict UUID validation on every id
73
+ * - b.pagination — HMAC-tagged cursor for listWriteoffs
74
+ * - inventoryLocations — sole owner of inventory_stock
75
+ * mutation; this primitive composes
76
+ * adjustStock to debit / restore.
77
+ * - costLayers (optional) — when present, owns the cost-impact
78
+ * accounting.
79
+ *
80
+ * Strict-monotonic clock: per-SKU `occurred_at` is bumped to
81
+ * `prior + 1` when the operator-supplied (or `Date.now()`) value
82
+ * would tie or land older than the most recent write-off for the
83
+ * same SKU. Two write-offs in the same millisecond don't collide
84
+ * on the timeline — `listWriteoffs` ordering is unambiguous.
85
+ *
86
+ * Three-tier input validation (use the discipline; don't write the
87
+ * labels): every public verb is a config-time entry point OR a
88
+ * defensive request-shape reader. Both throw on bad input. No
89
+ * drop-silent hot-path sinks.
90
+ */
91
+
92
+ var bShop;
93
+ function _b() {
94
+ if (!bShop) bShop = require("./index");
95
+ return bShop.framework;
96
+ }
97
+
98
+ // ---- constants ----------------------------------------------------------
99
+
100
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
101
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
102
+ var ACTOR_RE = /^[\S\s]{1,256}$/;
103
+ var PRINTABLE_RE = /^[^\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+$/;
104
+ var MAX_NOTES = 4096;
105
+ var MAX_REASON_TEXT = 280;
106
+ var MAX_LIST_LIMIT = 200;
107
+
108
+ var WRITEOFF_REASONS = Object.freeze([
109
+ "damaged", "lost", "shrinkage", "expired",
110
+ "recall", "sample", "quality_control", "theft",
111
+ ]);
112
+ var WRITEOFF_STATUSES = Object.freeze(["recorded", "reversed"]);
113
+ var WRITEOFF_ORDER_KEY = ["occurred_at:desc", "id:desc"];
114
+
115
+ // ---- validators ---------------------------------------------------------
116
+
117
+ function _id(s, label) {
118
+ try {
119
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
120
+ } catch (e) {
121
+ throw new TypeError("inventory-writeoffs: " + (label || "id") +
122
+ " — " + (e && e.message || "invalid UUID"));
123
+ }
124
+ }
125
+ function _sku(s) {
126
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
127
+ throw new TypeError("inventory-writeoffs: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
128
+ }
129
+ return s;
130
+ }
131
+ function _code(s, label) {
132
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
133
+ throw new TypeError("inventory-writeoffs: " + (label || "location_code") +
134
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
135
+ }
136
+ return s;
137
+ }
138
+ function _optCode(s, label) {
139
+ if (s == null) return null;
140
+ return _code(s, label);
141
+ }
142
+ function _positiveInt(n, label) {
143
+ if (!Number.isInteger(n) || n <= 0) {
144
+ throw new TypeError("inventory-writeoffs: " + label + " must be a positive integer");
145
+ }
146
+ return n;
147
+ }
148
+ function _reason(s) {
149
+ if (typeof s !== "string" || WRITEOFF_REASONS.indexOf(s) === -1) {
150
+ throw new TypeError("inventory-writeoffs: reason must be one of " +
151
+ WRITEOFF_REASONS.join(", ") + ", got " + JSON.stringify(s));
152
+ }
153
+ return s;
154
+ }
155
+ function _actor(s) {
156
+ if (typeof s !== "string" || !ACTOR_RE.test(s) || s.length > 256) {
157
+ throw new TypeError("inventory-writeoffs: actor must be a non-empty string ≤ 256 chars");
158
+ }
159
+ if (!PRINTABLE_RE.test(s)) {
160
+ throw new TypeError("inventory-writeoffs: actor must not contain control bytes other than tab/newline/CR");
161
+ }
162
+ return s;
163
+ }
164
+ function _notes(s) {
165
+ if (s == null) return null;
166
+ if (typeof s !== "string") {
167
+ throw new TypeError("inventory-writeoffs: notes must be a string or null");
168
+ }
169
+ if (s.length > MAX_NOTES) {
170
+ throw new TypeError("inventory-writeoffs: notes must be ≤ " + MAX_NOTES + " chars");
171
+ }
172
+ if (s.length && !PRINTABLE_RE.test(s)) {
173
+ throw new TypeError("inventory-writeoffs: notes must not contain control bytes other than tab/newline/CR");
174
+ }
175
+ return s;
176
+ }
177
+ function _reverseReason(s) {
178
+ if (typeof s !== "string" || !s.length) {
179
+ throw new TypeError("inventory-writeoffs: reverse reason must be a non-empty string");
180
+ }
181
+ if (s.length > MAX_REASON_TEXT) {
182
+ throw new TypeError("inventory-writeoffs: reverse reason must be ≤ " + MAX_REASON_TEXT + " chars");
183
+ }
184
+ if (!PRINTABLE_RE.test(s)) {
185
+ throw new TypeError("inventory-writeoffs: reverse reason must not contain control bytes other than tab/newline/CR");
186
+ }
187
+ return s;
188
+ }
189
+ function _epochMs(ts, label) {
190
+ if (ts == null) return null;
191
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
192
+ throw new TypeError("inventory-writeoffs: " + label + " must be a non-negative integer epoch-ms");
193
+ }
194
+ return ts;
195
+ }
196
+ function _limit(n) {
197
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
198
+ throw new TypeError("inventory-writeoffs: limit must be an integer in 1..." + MAX_LIST_LIMIT);
199
+ }
200
+ return n;
201
+ }
202
+
203
+ function _now() { return Date.now(); }
204
+
205
+ // ---- factory ------------------------------------------------------------
206
+
207
+ function create(opts) {
208
+ opts = opts || {};
209
+ // inventoryLocations is mandatory — every write-off MUST debit the
210
+ // shelf, so wiring without it is a misconfigured operator boot.
211
+ if (!opts.inventoryLocations ||
212
+ typeof opts.inventoryLocations.adjustStock !== "function") {
213
+ throw new TypeError("inventory-writeoffs.create: opts.inventoryLocations with adjustStock is required");
214
+ }
215
+ var locations = opts.inventoryLocations;
216
+ // costLayers is optional — when wired, every recordWriteoff also
217
+ // calls consumeForSale to attribute COGS-equivalent cost; without
218
+ // it, cost_impact_minor stays NULL and costImpactForPeriod returns
219
+ // a zero-total for the period.
220
+ var costLayers = opts.costLayers != null ? opts.costLayers : null;
221
+ if (costLayers !== null) {
222
+ if (typeof costLayers.consumeForSale !== "function" ||
223
+ typeof costLayers.recordReversal !== "function") {
224
+ throw new TypeError("inventory-writeoffs.create: opts.costLayers must expose consumeForSale + recordReversal when wired");
225
+ }
226
+ }
227
+ var query = opts.query;
228
+ if (!query) {
229
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
230
+ }
231
+ // Pagination cursors are HMAC-tagged so an operator can't tamper or
232
+ // replay across deployments. Tests inject a fixed dev string;
233
+ // production must supply an explicit secret.
234
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
235
+ if (process.env.NODE_ENV === "production") {
236
+ throw new Error("inventory-writeoffs.create: opts.cursorSecret is required in production");
237
+ }
238
+ opts.cursorSecret = "inventory-writeoffs-cursor-secret-dev-only";
239
+ }
240
+ var cursorSecret = opts.cursorSecret;
241
+
242
+ // Latest occurred_at for a SKU; null when the SKU has no prior
243
+ // write-off. Used by the strict-monotonic clock so two write-offs
244
+ // in the same millisecond don't tie on the timeline ordering key.
245
+ async function _latestWriteoffTs(sku) {
246
+ var r = await query(
247
+ "SELECT MAX(occurred_at) AS ts FROM inventory_writeoffs WHERE sku = ?1",
248
+ [sku],
249
+ );
250
+ if (!r.rows.length || r.rows[0].ts == null) return null;
251
+ return r.rows[0].ts;
252
+ }
253
+
254
+ function _resolveOccurredAt(requestedTs, latestTs) {
255
+ if (latestTs == null) return requestedTs;
256
+ if (requestedTs > latestTs) return requestedTs;
257
+ return latestTs + 1;
258
+ }
259
+
260
+ async function _getWriteoffRow(id) {
261
+ var r = await query("SELECT * FROM inventory_writeoffs WHERE id = ?1", [id]);
262
+ if (!r.rows.length) return null;
263
+ return r.rows[0];
264
+ }
265
+
266
+ return {
267
+
268
+ // Record a write-off. Validates input, debits the shelf via
269
+ // inventoryLocations.adjustStock, and (when costLayers is wired)
270
+ // composes consumeForSale so the cost-impact column carries a
271
+ // real COGS-equivalent value. Returns the hydrated row.
272
+ recordWriteoff: async function (input) {
273
+ if (!input || typeof input !== "object") {
274
+ throw new TypeError("inventory-writeoffs.recordWriteoff: input object required");
275
+ }
276
+ var sku = _sku(input.sku);
277
+ var locCode = _optCode(input.location_code, "location_code");
278
+ var quantity = _positiveInt(input.quantity, "quantity");
279
+ var reason = _reason(input.reason);
280
+ var actor = _actor(input.actor);
281
+ var notes = _notes(input.notes);
282
+ var requested = _epochMs(input.occurred_at, "occurred_at");
283
+ if (requested == null) requested = _now();
284
+ var latestTs = await _latestWriteoffTs(sku);
285
+ var occurredAt = _resolveOccurredAt(requested, latestTs);
286
+
287
+ var id = _b().uuid.v7();
288
+
289
+ // Debit the shelf first. When location_code is null we don't
290
+ // touch a specific shelf — the operator is recording a "global"
291
+ // write-off (catalog-level shrinkage adjustment with no per-
292
+ // location accounting). When location_code is set, the
293
+ // adjustStock refuses if the shelf doesn't have enough; that
294
+ // refusal propagates with no compensating action needed
295
+ // because no row has been written yet.
296
+ if (locCode !== null) {
297
+ await locations.adjustStock({
298
+ sku: sku,
299
+ location_code: locCode,
300
+ delta: -quantity,
301
+ reason: "writeoff:" + reason + ":" + id,
302
+ });
303
+ }
304
+
305
+ // Attribute COGS-equivalent cost when costLayers is wired. The
306
+ // synthetic order_id `writeoff:<id>` keeps the cost-layer
307
+ // attribution discoverable from this primitive (reverseWriteoff
308
+ // composes recordReversal with the same id pair to restore
309
+ // layers cleanly).
310
+ var costImpactMinor = null;
311
+ var currency = null;
312
+ if (costLayers !== null) {
313
+ try {
314
+ var consumed = await costLayers.consumeForSale({
315
+ sku: sku,
316
+ quantity: quantity,
317
+ order_id: "writeoff:" + id,
318
+ line_id: "1",
319
+ occurred_at: occurredAt,
320
+ });
321
+ costImpactMinor = consumed.total_cogs_minor;
322
+ currency = consumed.currency;
323
+ } catch (e) {
324
+ // The cost-layer pool refused (no on-hand layers yet, or
325
+ // currency drift across layers). Restore the shelf debit
326
+ // so the storefront doesn't disagree with the write-off
327
+ // record, then surface the original refusal — the operator
328
+ // either records a receipt first or reconciles currency
329
+ // before retrying.
330
+ if (locCode !== null) {
331
+ try {
332
+ await locations.adjustStock({
333
+ sku: sku,
334
+ location_code: locCode,
335
+ delta: quantity,
336
+ reason: "writeoff:rollback:" + id,
337
+ });
338
+ } catch (_e2) { /* drop-silent — the original cost-layer error is the operator's signal */ }
339
+ }
340
+ throw e;
341
+ }
342
+ }
343
+
344
+ try {
345
+ await query(
346
+ "INSERT INTO inventory_writeoffs " +
347
+ "(id, sku, location_code, quantity, reason, actor, notes, " +
348
+ " cost_impact_minor, currency, status, occurred_at) " +
349
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'recorded', ?10)",
350
+ [id, sku, locCode, quantity, reason, actor, notes,
351
+ costImpactMinor, currency, occurredAt],
352
+ );
353
+ } catch (e3) {
354
+ // The header row failed to land. Restore the cost-layer
355
+ // consumption AND the shelf debit so the system returns to
356
+ // its pre-call state.
357
+ if (costLayers !== null) {
358
+ try {
359
+ await costLayers.recordReversal({
360
+ order_id: "writeoff:" + id,
361
+ line_id: "1",
362
+ reason: "writeoff:rollback:" + id,
363
+ });
364
+ } catch (_e4) { /* drop-silent — the original DB error is the operator's signal */ }
365
+ }
366
+ if (locCode !== null) {
367
+ try {
368
+ await locations.adjustStock({
369
+ sku: sku,
370
+ location_code: locCode,
371
+ delta: quantity,
372
+ reason: "writeoff:rollback:" + id,
373
+ });
374
+ } catch (_e5) { /* drop-silent — the original DB error is the operator's signal */ }
375
+ }
376
+ throw e3;
377
+ }
378
+
379
+ return await _getWriteoffRow(id);
380
+ },
381
+
382
+ // Hydrated row or null on miss.
383
+ getWriteoff: async function (writeoffId) {
384
+ var id = _id(writeoffId, "id");
385
+ return await _getWriteoffRow(id);
386
+ },
387
+
388
+ // Paginated read with optional filters. Ordered by
389
+ // (occurred_at DESC, id DESC) — the most recent write-offs at
390
+ // the top, deterministic tie-break on id for cursor stability.
391
+ listWriteoffs: async function (listOpts) {
392
+ listOpts = listOpts || {};
393
+ var from = _epochMs(listOpts.from, "from");
394
+ var to = _epochMs(listOpts.to, "to");
395
+ var reasonF = null;
396
+ if (listOpts.reason != null) reasonF = _reason(listOpts.reason);
397
+ var locF = null;
398
+ if (listOpts.location_code != null) locF = _code(listOpts.location_code, "location_code");
399
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
400
+ _limit(limit);
401
+
402
+ var cursorVals = null;
403
+ if (listOpts.cursor != null) {
404
+ if (typeof listOpts.cursor !== "string") {
405
+ throw new TypeError("inventory-writeoffs.listWriteoffs: cursor must be an opaque string or null");
406
+ }
407
+ try {
408
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
409
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(WRITEOFF_ORDER_KEY)) {
410
+ throw new TypeError("inventory-writeoffs.listWriteoffs: cursor orderKey mismatch");
411
+ }
412
+ cursorVals = state.vals;
413
+ } catch (e) {
414
+ if (e instanceof TypeError) throw e;
415
+ throw new TypeError("inventory-writeoffs.listWriteoffs: cursor — " +
416
+ (e && e.message || "malformed"));
417
+ }
418
+ }
419
+
420
+ // Build the WHERE clause incrementally so omitted filters land
421
+ // no parameter at all (clean explain plan vs. always-passing
422
+ // `?1 IS NULL OR col = ?1` shape).
423
+ var clauses = [];
424
+ var params = [];
425
+ var idx = 1;
426
+ if (from != null) { clauses.push("occurred_at >= ?" + idx); params.push(from); idx += 1; }
427
+ if (to != null) { clauses.push("occurred_at < ?" + idx); params.push(to); idx += 1; }
428
+ if (reasonF != null) { clauses.push("reason = ?" + idx); params.push(reasonF); idx += 1; }
429
+ if (locF != null) { clauses.push("location_code = ?" + idx); params.push(locF); idx += 1; }
430
+ if (cursorVals) {
431
+ clauses.push("(occurred_at < ?" + idx + " OR (occurred_at = ?" + idx + " AND id < ?" + (idx + 1) + "))");
432
+ params.push(cursorVals[0]); idx += 1;
433
+ params.push(cursorVals[1]); idx += 1;
434
+ }
435
+ var where = clauses.length ? "WHERE " + clauses.join(" AND ") + " " : "";
436
+ params.push(limit);
437
+ var sql = "SELECT * FROM inventory_writeoffs " + where +
438
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
439
+ var rows = (await query(sql, params)).rows;
440
+ var last = rows[rows.length - 1];
441
+ var next = null;
442
+ if (last && rows.length === limit) {
443
+ next = _b().pagination.encodeCursor({
444
+ orderKey: WRITEOFF_ORDER_KEY,
445
+ vals: [last.occurred_at, last.id],
446
+ forward: true,
447
+ }, cursorSecret);
448
+ }
449
+ return { rows: rows, next_cursor: next };
450
+ },
451
+
452
+ // Sum cost_impact_minor over the period, optionally narrowed by
453
+ // reason. Reversed rows are excluded (the operator un-did them;
454
+ // they didn't actually cost anything). Mixed-currency rows are
455
+ // refused — the operator reconciles before pulling a meaningful
456
+ // total. Returns per-reason + grand-total.
457
+ costImpactForPeriod: async function (input) {
458
+ if (!input || typeof input !== "object") {
459
+ throw new TypeError("inventory-writeoffs.costImpactForPeriod: input object required");
460
+ }
461
+ var from = _epochMs(input.from, "from");
462
+ var to = _epochMs(input.to, "to");
463
+ if (from == null || to == null) {
464
+ throw new TypeError("inventory-writeoffs.costImpactForPeriod: from and to are required (epoch-ms)");
465
+ }
466
+ if (to <= from) {
467
+ throw new TypeError("inventory-writeoffs.costImpactForPeriod: to must be > from");
468
+ }
469
+ var reasonF = null;
470
+ if (input.reason != null) reasonF = _reason(input.reason);
471
+
472
+ var clauses = ["occurred_at >= ?1", "occurred_at < ?2",
473
+ "status = 'recorded'", "cost_impact_minor IS NOT NULL"];
474
+ var params = [from, to];
475
+ if (reasonF != null) {
476
+ clauses.push("reason = ?3");
477
+ params.push(reasonF);
478
+ }
479
+ var sql = "SELECT reason, currency, SUM(cost_impact_minor) AS total " +
480
+ "FROM inventory_writeoffs WHERE " + clauses.join(" AND ") +
481
+ " GROUP BY reason, currency ORDER BY reason ASC";
482
+ var rows = (await query(sql, params)).rows;
483
+
484
+ // Currency-coherence gate: every grouped row in the period must
485
+ // share the same currency, or the grand-total is meaningless.
486
+ // When the operator hasn't recorded any cost-impact rows yet
487
+ // (no costLayers wiring, or all writeoffs had no on-hand
488
+ // layers), the rowset is empty and the answer is a zero-total.
489
+ var currency = null;
490
+ var byReason = [];
491
+ var grandTotal = 0;
492
+ for (var i = 0; i < rows.length; i += 1) {
493
+ if (currency == null) currency = rows[i].currency;
494
+ else if (rows[i].currency !== currency) {
495
+ throw new TypeError("inventory-writeoffs.costImpactForPeriod: rows in period " +
496
+ "span multiple currencies (" + currency + ", " + rows[i].currency +
497
+ ") — reconcile before reporting");
498
+ }
499
+ // SQLite SUM() returns the integer as a JS number when in
500
+ // range; coerce defensively in case the underlying driver
501
+ // hands back a BigInt for very large totals.
502
+ var t = typeof rows[i].total === "bigint" ? Number(rows[i].total) : rows[i].total;
503
+ byReason.push({ reason: rows[i].reason, total_cogs_minor: t });
504
+ grandTotal += t;
505
+ }
506
+ return {
507
+ from: from,
508
+ to: to,
509
+ by_reason: byReason,
510
+ total_cost_impact_minor: grandTotal,
511
+ currency: currency,
512
+ };
513
+ },
514
+
515
+ // Restore a write-off. Adds stock back via
516
+ // inventoryLocations.adjustStock(+qty); when the original write-
517
+ // off had a cost-impact attribution, composes
518
+ // costLayers.recordReversal with the same synthetic order_id /
519
+ // line_id pair so the cost-layer pool gets the unit back at its
520
+ // original per-unit cost. Marks the row reversed + stamps
521
+ // reversed_at / reverse_reason. Refuses on already-reversed
522
+ // rows (no double-reverse).
523
+ reverseWriteoff: async function (input) {
524
+ if (!input || typeof input !== "object") {
525
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: input object required");
526
+ }
527
+ var id = _id(input.id, "id");
528
+ var reason = _reverseReason(input.reason);
529
+ var row = await _getWriteoffRow(id);
530
+ if (!row) {
531
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id + " not found");
532
+ }
533
+ if (row.status !== "recorded") {
534
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
535
+ " is " + row.status + ", only recorded writeoffs can be reversed");
536
+ }
537
+ // Restore the shelf. Mirrors the recordWriteoff logic: when
538
+ // location_code is null the original write-off didn't touch a
539
+ // specific shelf, so nothing to restore.
540
+ if (row.location_code != null) {
541
+ await locations.adjustStock({
542
+ sku: row.sku,
543
+ location_code: row.location_code,
544
+ delta: row.quantity,
545
+ reason: "writeoff:reverse:" + id,
546
+ });
547
+ }
548
+ // Restore the cost layer pool when the original write-off
549
+ // attributed COGS. costLayers may have been unwired since the
550
+ // original write-off — the operator gets a clear refusal in
551
+ // that case rather than a silent skip that desynchronizes the
552
+ // shelf from the cost ledger.
553
+ if (row.cost_impact_minor != null) {
554
+ if (costLayers === null) {
555
+ // Compensating action: undo the shelf restore so the
556
+ // operator sees a clean refusal rather than a half-applied
557
+ // reversal.
558
+ if (row.location_code != null) {
559
+ try {
560
+ await locations.adjustStock({
561
+ sku: row.sku,
562
+ location_code: row.location_code,
563
+ delta: -row.quantity,
564
+ reason: "writeoff:reverse:rollback:" + id,
565
+ });
566
+ } catch (_e1) { /* drop-silent — original refusal is the operator's signal */ }
567
+ }
568
+ throw new TypeError("inventory-writeoffs.reverseWriteoff: writeoff " + id +
569
+ " carries a cost-impact attribution but costLayers is not wired — " +
570
+ "rewire costLayers before reversing this row");
571
+ }
572
+ try {
573
+ await costLayers.recordReversal({
574
+ order_id: "writeoff:" + id,
575
+ line_id: "1",
576
+ reason: reason,
577
+ });
578
+ } catch (e) {
579
+ if (row.location_code != null) {
580
+ try {
581
+ await locations.adjustStock({
582
+ sku: row.sku,
583
+ location_code: row.location_code,
584
+ delta: -row.quantity,
585
+ reason: "writeoff:reverse:rollback:" + id,
586
+ });
587
+ } catch (_e2) { /* drop-silent — the costLayers error is the operator's signal */ }
588
+ }
589
+ throw e;
590
+ }
591
+ }
592
+ var ts = _now();
593
+ try {
594
+ await query(
595
+ "UPDATE inventory_writeoffs SET status = 'reversed', reversed_at = ?1, " +
596
+ "reverse_reason = ?2 WHERE id = ?3",
597
+ [ts, reason, id],
598
+ );
599
+ } catch (e3) {
600
+ if (row.cost_impact_minor != null && costLayers !== null) {
601
+ // Best-effort compensating: re-consume the cost layer so
602
+ // the cost ledger doesn't sit in a contradictory state.
603
+ // Failure here is drop-silent because the original DB
604
+ // error is what the operator needs to fix; the audit row
605
+ // on cost_layers still tells the story.
606
+ try {
607
+ await costLayers.consumeForSale({
608
+ sku: row.sku,
609
+ quantity: row.quantity,
610
+ order_id: "writeoff:" + id,
611
+ line_id: "1",
612
+ });
613
+ } catch (_e4) { /* drop-silent — original DB error is the operator's signal */ }
614
+ }
615
+ if (row.location_code != null) {
616
+ try {
617
+ await locations.adjustStock({
618
+ sku: row.sku,
619
+ location_code: row.location_code,
620
+ delta: -row.quantity,
621
+ reason: "writeoff:reverse:rollback:" + id,
622
+ });
623
+ } catch (_e5) { /* drop-silent — original DB error is the operator's signal */ }
624
+ }
625
+ throw e3;
626
+ }
627
+ return await _getWriteoffRow(id);
628
+ },
629
+ };
630
+ }
631
+
632
+ module.exports = {
633
+ create: create,
634
+ WRITEOFF_REASONS: WRITEOFF_REASONS,
635
+ WRITEOFF_STATUSES: WRITEOFF_STATUSES,
636
+ };