@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,752 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.creditLimits
4
+ * @title Credit-limits primitive — B2B credit accounts with
5
+ * outstanding-balance accounting + aging reports
6
+ *
7
+ * @intro
8
+ * B2B customers buy on terms: an operator-defined credit_limit +
9
+ * payment_terms_days (net-15 / net-30 / net-60) + billing_cycle
10
+ * (weekly / biweekly / monthly). Orders charge against the
11
+ * account up to the limit; an invoice is rendered at end-of-cycle;
12
+ * the customer pays the invoice and the outstanding balance
13
+ * reduces.
14
+ *
15
+ * Distinct from `storeCredit` (a prepaid wallet drained by
16
+ * debits). Here the customer is borrowing against the operator's
17
+ * credit line — `outstanding_balance` grows when orders charge,
18
+ * shrinks when payments arrive. `available_credit` is
19
+ * `credit_limit - outstanding`.
20
+ *
21
+ * Composition:
22
+ * var credit = bShop.creditLimits.create({ query: q });
23
+ * await credit.defineAccount({
24
+ * customer_id: custId,
25
+ * credit_limit_minor: 500000, // $5,000
26
+ * currency: "USD",
27
+ * payment_terms_days: 30,
28
+ * billing_cycle: "monthly",
29
+ * });
30
+ * await credit.chargeOrder({
31
+ * customer_id: custId,
32
+ * order_id: orderId,
33
+ * amount_minor: 12500,
34
+ * });
35
+ * var avail = await credit.availableCredit(custId);
36
+ * // { credit_limit_minor: 500000, outstanding_minor: 12500,
37
+ * // available_minor: 487500 }
38
+ *
39
+ * FSM (`credit_accounts.status`):
40
+ *
41
+ * defineAccount → active
42
+ * active → suspended (via suspendAccount, requires reason)
43
+ * suspended → active (via reinstateAccount, clears reason)
44
+ * any → closed (terminal — no transition out)
45
+ *
46
+ * `chargeOrder` refuses with code `CREDIT_LIMIT_EXCEEDED` when the
47
+ * pending balance after the charge would exceed the limit, and
48
+ * refuses with code `CREDIT_ACCOUNT_NOT_ACTIVE` when the account
49
+ * is suspended or closed. Refusals write no transaction row.
50
+ *
51
+ * `releaseHold` reverses a prior charge/hold for the same
52
+ * order_id — credits the customer back. Refuses if the order_id
53
+ * never charged.
54
+ *
55
+ * `agingReport` answers the AR question "how old is each slice of
56
+ * the outstanding balance?" — bucketed by charge age into
57
+ * `current` (within payment_terms_days), `30d`, `60d`, `90d_plus`.
58
+ * Payments are applied FIFO against the oldest outstanding
59
+ * charges; what's left in each charge after FIFO settlement is
60
+ * what the report bucketizes.
61
+ *
62
+ * Monotonic per-customer `occurred_at`: two writes in the same
63
+ * millisecond would tie and make the "latest row" ambiguous for
64
+ * the denormalized balance read. `_resolveOccurredAt` bumps the
65
+ * requested timestamp to `prior + 1` on collision, guaranteeing
66
+ * strict monotonicity in the `(customer_id, occurred_at DESC)`
67
+ * index.
68
+ *
69
+ * Surface:
70
+ * - defineAccount({ customer_id, credit_limit_minor, currency,
71
+ * payment_terms_days, billing_cycle })
72
+ * - getAccount(customer_id)
73
+ * - listAccounts({ status? })
74
+ * - updateAccount(customer_id, patch)
75
+ * - suspendAccount({ customer_id, reason })
76
+ * - reinstateAccount(customer_id)
77
+ * - chargeOrder({ customer_id, order_id, amount_minor, occurred_at? })
78
+ * - releaseHold({ customer_id, order_id, occurred_at? })
79
+ * - recordPayment({ customer_id, amount_minor, payment_ref,
80
+ * occurred_at? })
81
+ * - availableCredit(customer_id)
82
+ * - outstandingBalance(customer_id)
83
+ * - agingReport({ customer_id, now? })
84
+ *
85
+ * Storage:
86
+ * - credit_accounts (migration 0122)
87
+ * - credit_transactions (migration 0122)
88
+ *
89
+ * @primitive creditLimits
90
+ * @related b.uuid.v7, b.guardUuid, shop.storeCredit, shop.invoiceRenderer
91
+ */
92
+
93
+ var bShop;
94
+ function _b() {
95
+ if (!bShop) bShop = require("./index");
96
+ return bShop.framework;
97
+ }
98
+
99
+ var BILLING_CYCLES = ["weekly", "biweekly", "monthly"];
100
+ var STATUSES = ["active", "suspended", "closed"];
101
+ var KINDS = ["charge", "payment", "hold", "release", "adjustment"];
102
+
103
+ var MAX_REF_LEN = 128;
104
+ // payment_ref / suspended_reason / currency are short correlation
105
+ // fields. Refuse all control bytes (including CR/LF and tab) — log-
106
+ // injection cover has no legitimate place in a one-line column.
107
+ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
108
+
109
+ var MS_PER_DAY = 86400 * 1000;
110
+
111
+ // Aging buckets. The boundary days are reported as part of the
112
+ // agingReport return shape so downstream invoice rendering doesn't
113
+ // re-derive the constants.
114
+ var AGE_BUCKETS = {
115
+ CURRENT: "current", // age <= payment_terms_days
116
+ D30: "d30", // age in (payment_terms_days, 30]
117
+ D60: "d60", // age in (30, 60]
118
+ D90: "d90_plus", // age > 60
119
+ };
120
+
121
+ // ---- validators ---------------------------------------------------------
122
+
123
+ function _uuid(s, label) {
124
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
125
+ catch (e) { throw new TypeError("creditLimits: " + label + " — " + (e && e.message || "invalid UUID")); }
126
+ }
127
+
128
+ function _amountMinor(n, label) {
129
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
130
+ throw new TypeError("creditLimits: " + label + " must be a positive integer (minor units)");
131
+ }
132
+ return n;
133
+ }
134
+
135
+ function _limitMinor(n, label) {
136
+ // Credit limit accepts zero — a freshly-defined account at zero
137
+ // limit blocks every charge until the operator raises it. Treated
138
+ // distinct from "positive amount" because amount_minor is always
139
+ // an event delta (must be > 0) while limits are stateful (can be
140
+ // legitimately zero or raised/lowered).
141
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
142
+ throw new TypeError("creditLimits: " + label + " must be a non-negative integer (minor units)");
143
+ }
144
+ return n;
145
+ }
146
+
147
+ function _termsDays(n, label) {
148
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
149
+ throw new TypeError("creditLimits: " + label + " must be a positive integer (days)");
150
+ }
151
+ return n;
152
+ }
153
+
154
+ function _billingCycle(s) {
155
+ if (typeof s !== "string" || BILLING_CYCLES.indexOf(s) === -1) {
156
+ throw new TypeError("creditLimits: billing_cycle must be one of " + BILLING_CYCLES.join(", "));
157
+ }
158
+ return s;
159
+ }
160
+
161
+ function _currency(s) {
162
+ if (typeof s !== "string" || s.length === 0) {
163
+ throw new TypeError("creditLimits: currency must be a non-empty string");
164
+ }
165
+ if (s.length > 16) {
166
+ throw new TypeError("creditLimits: currency must be <= 16 chars");
167
+ }
168
+ if (!PRINTABLE_RE.test(s)) {
169
+ throw new TypeError("creditLimits: currency must not contain control bytes");
170
+ }
171
+ return s;
172
+ }
173
+
174
+ function _ref(s, label, required) {
175
+ if (s == null) {
176
+ if (required) {
177
+ throw new TypeError("creditLimits: " + label + " is required");
178
+ }
179
+ return null;
180
+ }
181
+ if (typeof s !== "string") {
182
+ throw new TypeError("creditLimits: " + label + " must be a string");
183
+ }
184
+ if (!s.length) {
185
+ throw new TypeError("creditLimits: " + label + " must be a non-empty string when provided");
186
+ }
187
+ if (s.length > MAX_REF_LEN) {
188
+ throw new TypeError("creditLimits: " + label + " must be <= " + MAX_REF_LEN + " chars");
189
+ }
190
+ if (!PRINTABLE_RE.test(s)) {
191
+ throw new TypeError("creditLimits: " + label + " must not contain control bytes");
192
+ }
193
+ return s;
194
+ }
195
+
196
+ function _epochMs(ts, label) {
197
+ if (ts == null) return null;
198
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
199
+ throw new TypeError("creditLimits: " + label + " must be a non-negative integer epoch-ms");
200
+ }
201
+ return ts;
202
+ }
203
+
204
+ function _status(s) {
205
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
206
+ throw new TypeError("creditLimits: status must be one of " + STATUSES.join(", "));
207
+ }
208
+ return s;
209
+ }
210
+
211
+ function _now() { return Date.now(); }
212
+
213
+ // ---- factory ------------------------------------------------------------
214
+
215
+ function create(opts) {
216
+ opts = opts || {};
217
+ var query = opts.query;
218
+ if (!query) {
219
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
220
+ }
221
+ // Optional handle to the customers primitive. When provided,
222
+ // defineAccount confirms the customer row exists before writing.
223
+ // Tests pass a stub that satisfies `.get(id)`; production wires
224
+ // the real `bShop.customers.create(...)` instance.
225
+ var customers = opts.customers || null;
226
+
227
+ async function _requireCustomer(id, label) {
228
+ if (!customers) return;
229
+ var row = await customers.get(id);
230
+ if (!row) {
231
+ throw new TypeError("creditLimits: " + label + " " + JSON.stringify(id) + " not found in customers");
232
+ }
233
+ }
234
+
235
+ async function _readAccount(customerId) {
236
+ var r = await query(
237
+ "SELECT customer_id, credit_limit_minor, currency, payment_terms_days, billing_cycle, " +
238
+ "status, suspended_reason, suspended_at, created_at, updated_at " +
239
+ "FROM credit_accounts WHERE customer_id = ?1 LIMIT 1",
240
+ [customerId],
241
+ );
242
+ return r.rows.length ? r.rows[0] : null;
243
+ }
244
+
245
+ async function _readLatestTxn(customerId) {
246
+ var r = await query(
247
+ "SELECT balance_after_minor, occurred_at FROM credit_transactions " +
248
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
249
+ [customerId],
250
+ );
251
+ if (!r.rows.length) return { balance: 0, occurred_at: null };
252
+ return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
253
+ }
254
+
255
+ // Two writes against the same customer in the same millisecond
256
+ // would tie on `occurred_at` and make the "latest row" ambiguous
257
+ // for denormalized-balance reads. Bump to `prior + 1` on
258
+ // collision; the result is a strictly-monotonic per-customer
259
+ // `occurred_at` sequence in the `(customer_id, occurred_at DESC)`
260
+ // index.
261
+ function _resolveOccurredAt(requestedTs, latestTs) {
262
+ if (latestTs == null) return requestedTs;
263
+ if (requestedTs > latestTs) return requestedTs;
264
+ return latestTs + 1;
265
+ }
266
+
267
+ async function _writeTxn(customerId, kind, orderId, amountMinor, balanceAfter, paymentRef, ts) {
268
+ var id = _b().uuid.v7();
269
+ await query(
270
+ "INSERT INTO credit_transactions " +
271
+ "(id, customer_id, kind, order_id, amount_minor, balance_after_minor, payment_ref, occurred_at) " +
272
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
273
+ [id, customerId, kind, orderId, amountMinor, balanceAfter, paymentRef, ts],
274
+ );
275
+ return id;
276
+ }
277
+
278
+ async function _requireActive(customerId, op) {
279
+ var acct = await _readAccount(customerId);
280
+ if (!acct) {
281
+ var notFound = new Error("creditLimits." + op + ": no account for customer " + JSON.stringify(customerId));
282
+ notFound.code = "CREDIT_ACCOUNT_NOT_FOUND";
283
+ throw notFound;
284
+ }
285
+ if (acct.status !== "active") {
286
+ var notActive = new Error("creditLimits." + op + ": account status is " + acct.status + " — operation refused");
287
+ notActive.code = "CREDIT_ACCOUNT_NOT_ACTIVE";
288
+ throw notActive;
289
+ }
290
+ return acct;
291
+ }
292
+
293
+ return {
294
+ BILLING_CYCLES: BILLING_CYCLES.slice(),
295
+ STATUSES: STATUSES.slice(),
296
+ KINDS: KINDS.slice(),
297
+ AGE_BUCKETS: { CURRENT: AGE_BUCKETS.CURRENT, D30: AGE_BUCKETS.D30, D60: AGE_BUCKETS.D60, D90: AGE_BUCKETS.D90 },
298
+
299
+ defineAccount: async function (input) {
300
+ if (!input || typeof input !== "object") {
301
+ throw new TypeError("creditLimits.defineAccount: input object required");
302
+ }
303
+ var customerId = _uuid(input.customer_id, "customer_id");
304
+ var limit = _limitMinor(input.credit_limit_minor, "credit_limit_minor");
305
+ var currency = _currency(input.currency);
306
+ var terms = _termsDays(input.payment_terms_days, "payment_terms_days");
307
+ var cycle = _billingCycle(input.billing_cycle);
308
+
309
+ await _requireCustomer(customerId, "customer_id");
310
+
311
+ var existing = await _readAccount(customerId);
312
+ if (existing) {
313
+ throw new TypeError("creditLimits.defineAccount: account for customer " +
314
+ JSON.stringify(customerId) + " already exists — use updateAccount");
315
+ }
316
+
317
+ var ts = _now();
318
+ await query(
319
+ "INSERT INTO credit_accounts " +
320
+ "(customer_id, credit_limit_minor, currency, payment_terms_days, billing_cycle, " +
321
+ " status, suspended_reason, suspended_at, created_at, updated_at) " +
322
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'active', NULL, NULL, ?6, ?7)",
323
+ [customerId, limit, currency, terms, cycle, ts, ts],
324
+ );
325
+ return {
326
+ customer_id: customerId,
327
+ credit_limit_minor: limit,
328
+ currency: currency,
329
+ payment_terms_days: terms,
330
+ billing_cycle: cycle,
331
+ status: "active",
332
+ suspended_reason: null,
333
+ suspended_at: null,
334
+ created_at: ts,
335
+ updated_at: ts,
336
+ };
337
+ },
338
+
339
+ getAccount: async function (customerId) {
340
+ _uuid(customerId, "customer_id");
341
+ return _readAccount(customerId);
342
+ },
343
+
344
+ listAccounts: async function (input) {
345
+ input = input || {};
346
+ var sql = "SELECT customer_id, credit_limit_minor, currency, payment_terms_days, billing_cycle, " +
347
+ "status, suspended_reason, suspended_at, created_at, updated_at " +
348
+ "FROM credit_accounts";
349
+ var params = [];
350
+ if (input.status != null) {
351
+ var s = _status(input.status);
352
+ sql += " WHERE status = ?1";
353
+ params.push(s);
354
+ }
355
+ sql += " ORDER BY created_at ASC, customer_id ASC";
356
+ var r = await query(sql, params);
357
+ return r.rows;
358
+ },
359
+
360
+ updateAccount: async function (customerId, patch) {
361
+ _uuid(customerId, "customer_id");
362
+ if (!patch || typeof patch !== "object") {
363
+ throw new TypeError("creditLimits.updateAccount: patch object required");
364
+ }
365
+ var acct = await _readAccount(customerId);
366
+ if (!acct) {
367
+ throw new TypeError("creditLimits.updateAccount: no account for customer " + JSON.stringify(customerId));
368
+ }
369
+ if (acct.status === "closed") {
370
+ throw new TypeError("creditLimits.updateAccount: account is closed — no further updates");
371
+ }
372
+
373
+ // Whitelist patchable columns. `status` transitions go through
374
+ // suspend/reinstate; `customer_id` is the primary key; the
375
+ // audit columns (`created_at`, `suspended_*`) are derived. The
376
+ // operator can change pricing/cadence shape here.
377
+ var sets = [];
378
+ var params = [];
379
+ var idx = 1;
380
+
381
+ if (Object.prototype.hasOwnProperty.call(patch, "credit_limit_minor")) {
382
+ var newLimit = _limitMinor(patch.credit_limit_minor, "credit_limit_minor");
383
+ sets.push("credit_limit_minor = ?" + idx); params.push(newLimit); idx += 1;
384
+ }
385
+ if (Object.prototype.hasOwnProperty.call(patch, "currency")) {
386
+ sets.push("currency = ?" + idx); params.push(_currency(patch.currency)); idx += 1;
387
+ }
388
+ if (Object.prototype.hasOwnProperty.call(patch, "payment_terms_days")) {
389
+ sets.push("payment_terms_days = ?" + idx); params.push(_termsDays(patch.payment_terms_days, "payment_terms_days")); idx += 1;
390
+ }
391
+ if (Object.prototype.hasOwnProperty.call(patch, "billing_cycle")) {
392
+ sets.push("billing_cycle = ?" + idx); params.push(_billingCycle(patch.billing_cycle)); idx += 1;
393
+ }
394
+ if (Object.prototype.hasOwnProperty.call(patch, "status")) {
395
+ // Status transitions through suspend/reinstate only — the
396
+ // FSM lives there. The only direct transition we accept on
397
+ // update is `closed` (terminal); anything else is rejected
398
+ // so callers don't bypass the suspension reason audit-trail.
399
+ var nextStatus = _status(patch.status);
400
+ if (nextStatus !== "closed") {
401
+ throw new TypeError("creditLimits.updateAccount: status transition via update is restricted to 'closed' — use suspendAccount / reinstateAccount otherwise");
402
+ }
403
+ sets.push("status = ?" + idx); params.push("closed"); idx += 1;
404
+ // If the account was suspended, closing clears the
405
+ // suspension metadata — it's no longer relevant.
406
+ sets.push("suspended_reason = NULL");
407
+ sets.push("suspended_at = NULL");
408
+ }
409
+
410
+ if (sets.length === 0) {
411
+ throw new TypeError("creditLimits.updateAccount: patch must contain at least one updatable field");
412
+ }
413
+ var ts = _now();
414
+ sets.push("updated_at = ?" + idx); params.push(ts); idx += 1;
415
+ params.push(customerId);
416
+
417
+ await query(
418
+ "UPDATE credit_accounts SET " + sets.join(", ") + " WHERE customer_id = ?" + idx,
419
+ params,
420
+ );
421
+ return _readAccount(customerId);
422
+ },
423
+
424
+ suspendAccount: async function (input) {
425
+ if (!input || typeof input !== "object") {
426
+ throw new TypeError("creditLimits.suspendAccount: input object required");
427
+ }
428
+ var customerId = _uuid(input.customer_id, "customer_id");
429
+ var reason = _ref(input.reason, "reason", true);
430
+
431
+ var acct = await _readAccount(customerId);
432
+ if (!acct) {
433
+ throw new TypeError("creditLimits.suspendAccount: no account for customer " + JSON.stringify(customerId));
434
+ }
435
+ if (acct.status === "closed") {
436
+ throw new TypeError("creditLimits.suspendAccount: account is closed — cannot suspend");
437
+ }
438
+ if (acct.status === "suspended") {
439
+ throw new TypeError("creditLimits.suspendAccount: account already suspended");
440
+ }
441
+ var ts = _now();
442
+ await query(
443
+ "UPDATE credit_accounts SET status = 'suspended', suspended_reason = ?1, suspended_at = ?2, updated_at = ?3 " +
444
+ "WHERE customer_id = ?4",
445
+ [reason, ts, ts, customerId],
446
+ );
447
+ return _readAccount(customerId);
448
+ },
449
+
450
+ reinstateAccount: async function (customerId) {
451
+ _uuid(customerId, "customer_id");
452
+ var acct = await _readAccount(customerId);
453
+ if (!acct) {
454
+ throw new TypeError("creditLimits.reinstateAccount: no account for customer " + JSON.stringify(customerId));
455
+ }
456
+ if (acct.status === "closed") {
457
+ throw new TypeError("creditLimits.reinstateAccount: account is closed — cannot reinstate");
458
+ }
459
+ if (acct.status === "active") {
460
+ throw new TypeError("creditLimits.reinstateAccount: account is already active");
461
+ }
462
+ var ts = _now();
463
+ await query(
464
+ "UPDATE credit_accounts SET status = 'active', suspended_reason = NULL, suspended_at = NULL, updated_at = ?1 " +
465
+ "WHERE customer_id = ?2",
466
+ [ts, customerId],
467
+ );
468
+ return _readAccount(customerId);
469
+ },
470
+
471
+ chargeOrder: async function (input) {
472
+ if (!input || typeof input !== "object") {
473
+ throw new TypeError("creditLimits.chargeOrder: input object required");
474
+ }
475
+ var customerId = _uuid(input.customer_id, "customer_id");
476
+ var orderId = _uuid(input.order_id, "order_id");
477
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
478
+ var requested = _epochMs(input.occurred_at, "occurred_at");
479
+ if (requested == null) requested = _now();
480
+
481
+ var acct = await _requireActive(customerId, "chargeOrder");
482
+ var latest = await _readLatestTxn(customerId);
483
+ var after = latest.balance + amount;
484
+ if (after > acct.credit_limit_minor) {
485
+ var over = new Error("creditLimits.chargeOrder: amount " + amount +
486
+ " would exceed credit limit (outstanding " + latest.balance +
487
+ " + amount " + amount + " > limit " + acct.credit_limit_minor + ")");
488
+ over.code = "CREDIT_LIMIT_EXCEEDED";
489
+ over.outstanding_minor = latest.balance;
490
+ over.credit_limit_minor = acct.credit_limit_minor;
491
+ over.attempted_minor = amount;
492
+ throw over;
493
+ }
494
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
495
+ var id = await _writeTxn(customerId, "charge", orderId, amount, after, null, ts);
496
+ return {
497
+ id: id,
498
+ customer_id: customerId,
499
+ kind: "charge",
500
+ order_id: orderId,
501
+ amount_minor: amount,
502
+ balance_after_minor: after,
503
+ occurred_at: ts,
504
+ };
505
+ },
506
+
507
+ releaseHold: async function (input) {
508
+ if (!input || typeof input !== "object") {
509
+ throw new TypeError("creditLimits.releaseHold: input object required");
510
+ }
511
+ var customerId = _uuid(input.customer_id, "customer_id");
512
+ var orderId = _uuid(input.order_id, "order_id");
513
+ var requested = _epochMs(input.occurred_at, "occurred_at");
514
+ if (requested == null) requested = _now();
515
+
516
+ // releaseHold acts on the same FSM as chargeOrder — closed/
517
+ // suspended accounts could refuse here, but the operator-
518
+ // facing case is "we suspended this customer; release their
519
+ // pending order so they aren't carrying the balance." Refuse
520
+ // only when the account doesn't exist; allow on suspended.
521
+ var acct = await _readAccount(customerId);
522
+ if (!acct) {
523
+ var notFound = new Error("creditLimits.releaseHold: no account for customer " + JSON.stringify(customerId));
524
+ notFound.code = "CREDIT_ACCOUNT_NOT_FOUND";
525
+ throw notFound;
526
+ }
527
+ if (acct.status === "closed") {
528
+ var closed = new Error("creditLimits.releaseHold: account is closed");
529
+ closed.code = "CREDIT_ACCOUNT_NOT_ACTIVE";
530
+ throw closed;
531
+ }
532
+
533
+ // Sum the customer's net exposure to this order: charge +
534
+ // hold sums minus prior release sums. Refuse if zero (the
535
+ // order was never charged, or has already been fully
536
+ // released) — re-releasing would drive the balance below the
537
+ // genuine outstanding and corrupt the ledger.
538
+ var sumRow = (await query(
539
+ "SELECT " +
540
+ " COALESCE(SUM(CASE WHEN kind IN ('charge', 'hold') THEN amount_minor ELSE 0 END), 0) AS charged, " +
541
+ " COALESCE(SUM(CASE WHEN kind = 'release' THEN amount_minor ELSE 0 END), 0) AS released " +
542
+ "FROM credit_transactions " +
543
+ "WHERE customer_id = ?1 AND order_id = ?2",
544
+ [customerId, orderId],
545
+ )).rows[0];
546
+ var charged = sumRow ? sumRow.charged : 0;
547
+ var released = sumRow ? sumRow.released : 0;
548
+ var remaining = charged - released;
549
+ if (remaining <= 0) {
550
+ var nothing = new Error("creditLimits.releaseHold: no outstanding charge for order " + JSON.stringify(orderId));
551
+ nothing.code = "CREDIT_RELEASE_NOT_FOUND";
552
+ throw nothing;
553
+ }
554
+
555
+ var latest = await _readLatestTxn(customerId);
556
+ // Cap the release at the outstanding balance — defensive
557
+ // against operator-induced ledger drift (a payment that
558
+ // somehow shrank the balance below the charge total
559
+ // shouldn't drive a release into negative territory).
560
+ var toRelease = remaining > latest.balance ? latest.balance : remaining;
561
+ if (toRelease <= 0) {
562
+ var drained = new Error("creditLimits.releaseHold: outstanding balance is zero — nothing to release");
563
+ drained.code = "CREDIT_RELEASE_NOT_FOUND";
564
+ throw drained;
565
+ }
566
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
567
+ var after = latest.balance - toRelease;
568
+ var id = await _writeTxn(customerId, "release", orderId, toRelease, after, null, ts);
569
+ return {
570
+ id: id,
571
+ customer_id: customerId,
572
+ kind: "release",
573
+ order_id: orderId,
574
+ amount_minor: toRelease,
575
+ balance_after_minor: after,
576
+ occurred_at: ts,
577
+ };
578
+ },
579
+
580
+ recordPayment: async function (input) {
581
+ if (!input || typeof input !== "object") {
582
+ throw new TypeError("creditLimits.recordPayment: input object required");
583
+ }
584
+ var customerId = _uuid(input.customer_id, "customer_id");
585
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
586
+ var paymentRef = _ref(input.payment_ref, "payment_ref", true);
587
+ var requested = _epochMs(input.occurred_at, "occurred_at");
588
+ if (requested == null) requested = _now();
589
+
590
+ var acct = await _readAccount(customerId);
591
+ if (!acct) {
592
+ var notFound = new Error("creditLimits.recordPayment: no account for customer " + JSON.stringify(customerId));
593
+ notFound.code = "CREDIT_ACCOUNT_NOT_FOUND";
594
+ throw notFound;
595
+ }
596
+ // Payments accepted even on suspended/closed accounts — a
597
+ // customer with a closed account paying down their balance is
598
+ // a legitimate AR workflow.
599
+
600
+ var latest = await _readLatestTxn(customerId);
601
+ // Refuse payments that exceed the outstanding balance —
602
+ // credit-on-account-from-overpayment is a separate primitive
603
+ // (store-credit) and the operator must route the overage
604
+ // there explicitly rather than implicitly inflating the
605
+ // outstanding into negative territory.
606
+ if (amount > latest.balance) {
607
+ var over = new Error("creditLimits.recordPayment: amount " + amount +
608
+ " exceeds outstanding balance " + latest.balance);
609
+ over.code = "CREDIT_PAYMENT_EXCEEDS_OUTSTANDING";
610
+ over.outstanding_minor = latest.balance;
611
+ over.attempted_minor = amount;
612
+ throw over;
613
+ }
614
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
615
+ var after = latest.balance - amount;
616
+ var id = await _writeTxn(customerId, "payment", null, amount, after, paymentRef, ts);
617
+ return {
618
+ id: id,
619
+ customer_id: customerId,
620
+ kind: "payment",
621
+ amount_minor: amount,
622
+ payment_ref: paymentRef,
623
+ balance_after_minor: after,
624
+ occurred_at: ts,
625
+ };
626
+ },
627
+
628
+ availableCredit: async function (customerId) {
629
+ _uuid(customerId, "customer_id");
630
+ var acct = await _readAccount(customerId);
631
+ if (!acct) {
632
+ throw new TypeError("creditLimits.availableCredit: no account for customer " + JSON.stringify(customerId));
633
+ }
634
+ var latest = await _readLatestTxn(customerId);
635
+ var avail = acct.credit_limit_minor - latest.balance;
636
+ // Suspended accounts surface zero available — the limit is
637
+ // still stored but no new charges can flow.
638
+ if (acct.status !== "active") avail = 0;
639
+ if (avail < 0) avail = 0;
640
+ return {
641
+ customer_id: customerId,
642
+ credit_limit_minor: acct.credit_limit_minor,
643
+ outstanding_minor: latest.balance,
644
+ available_minor: avail,
645
+ status: acct.status,
646
+ currency: acct.currency,
647
+ };
648
+ },
649
+
650
+ outstandingBalance: async function (customerId) {
651
+ _uuid(customerId, "customer_id");
652
+ var acct = await _readAccount(customerId);
653
+ if (!acct) {
654
+ throw new TypeError("creditLimits.outstandingBalance: no account for customer " + JSON.stringify(customerId));
655
+ }
656
+ var latest = await _readLatestTxn(customerId);
657
+ return {
658
+ customer_id: customerId,
659
+ outstanding_minor: latest.balance,
660
+ currency: acct.currency,
661
+ };
662
+ },
663
+
664
+ agingReport: async function (input) {
665
+ if (!input || typeof input !== "object") {
666
+ throw new TypeError("creditLimits.agingReport: input object required");
667
+ }
668
+ var customerId = _uuid(input.customer_id, "customer_id");
669
+ var now = _epochMs(input.now, "now");
670
+ if (now == null) now = _now();
671
+
672
+ var acct = await _readAccount(customerId);
673
+ if (!acct) {
674
+ throw new TypeError("creditLimits.agingReport: no account for customer " + JSON.stringify(customerId));
675
+ }
676
+ var terms = acct.payment_terms_days;
677
+
678
+ // Walk all charge/hold rows oldest-first. Apply payments +
679
+ // releases FIFO against the oldest outstanding amounts. What
680
+ // remains in each charge row after settlement is the
681
+ // outstanding slice for that age bucket.
682
+ var chargeRows = (await query(
683
+ "SELECT amount_minor, occurred_at, order_id FROM credit_transactions " +
684
+ "WHERE customer_id = ?1 AND kind IN ('charge', 'hold') " +
685
+ "ORDER BY occurred_at ASC",
686
+ [customerId],
687
+ )).rows;
688
+
689
+ var settleRow = (await query(
690
+ "SELECT COALESCE(SUM(amount_minor), 0) AS total FROM credit_transactions " +
691
+ "WHERE customer_id = ?1 AND kind IN ('payment', 'release')",
692
+ [customerId],
693
+ )).rows[0];
694
+ var toSettle = settleRow ? settleRow.total : 0;
695
+
696
+ var buckets = {};
697
+ buckets[AGE_BUCKETS.CURRENT] = 0;
698
+ buckets[AGE_BUCKETS.D30] = 0;
699
+ buckets[AGE_BUCKETS.D60] = 0;
700
+ buckets[AGE_BUCKETS.D90] = 0;
701
+
702
+ var total = 0;
703
+ for (var i = 0; i < chargeRows.length; i += 1) {
704
+ var row = chargeRows[i];
705
+ var remaining = row.amount_minor;
706
+ if (toSettle > 0) {
707
+ var settled = toSettle > remaining ? remaining : toSettle;
708
+ remaining -= settled;
709
+ toSettle -= settled;
710
+ }
711
+ if (remaining <= 0) continue;
712
+ var ageMs = now - row.occurred_at;
713
+ var ageDays = Math.floor(ageMs / MS_PER_DAY);
714
+ // Bucket by days-past-due so the report reads the same
715
+ // regardless of the account's payment terms — a net-15 and
716
+ // a net-60 account both report `current` against not-yet-
717
+ // due charges, then advance through 30 / 60 / 90+ buckets
718
+ // measured from the due date.
719
+ var pastDue = ageDays - terms;
720
+ var bucket;
721
+ if (pastDue <= 0) bucket = AGE_BUCKETS.CURRENT;
722
+ else if (pastDue <= 30) bucket = AGE_BUCKETS.D30;
723
+ else if (pastDue <= 60) bucket = AGE_BUCKETS.D60;
724
+ else bucket = AGE_BUCKETS.D90;
725
+ buckets[bucket] += remaining;
726
+ total += remaining;
727
+ }
728
+
729
+ return {
730
+ customer_id: customerId,
731
+ currency: acct.currency,
732
+ payment_terms_days: terms,
733
+ total_outstanding_minor: total,
734
+ buckets: {
735
+ current: buckets[AGE_BUCKETS.CURRENT],
736
+ d30: buckets[AGE_BUCKETS.D30],
737
+ d60: buckets[AGE_BUCKETS.D60],
738
+ d90_plus: buckets[AGE_BUCKETS.D90],
739
+ },
740
+ as_of: now,
741
+ };
742
+ },
743
+ };
744
+ }
745
+
746
+ module.exports = {
747
+ create: create,
748
+ BILLING_CYCLES: BILLING_CYCLES,
749
+ STATUSES: STATUSES,
750
+ KINDS: KINDS,
751
+ AGE_BUCKETS: AGE_BUCKETS,
752
+ };