@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,757 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.shippingInsurance
4
+ * @title Shipping insurance — per-shipment third-party parcel insurance
5
+ *
6
+ * @intro
7
+ * Per-shipment insurance via Shipsurance / Loop / U-PIC / Route.
8
+ * The customer opts in for high-value packages at checkout; the
9
+ * storefront calls `quoteInsurance` against the operator-registered
10
+ * provider to get the premium, then `purchaseInsurance` to mint the
11
+ * row + snapshot the premium. The carrier never sees the
12
+ * insurance — it's a parallel agreement between the customer, the
13
+ * operator, and the insurer.
14
+ *
15
+ * The framework does NOT call Shipsurance / Loop / U-PIC / Route
16
+ * APIs directly. Each insurer has its own auth + endpoint + claim
17
+ * portal; the operator's worker drives the integration and calls
18
+ * back into this primitive (`purchaseInsurance` with the minted
19
+ * external_policy_id; `markClaimApproved` / `markClaimDenied`
20
+ * with the insurer's decision). The primitive owns the lifecycle
21
+ * bookkeeping, the premium-quote math, and the claim FSM.
22
+ *
23
+ * Premium math: `premium = max(premium_min_minor,
24
+ * ceil(declared_value_minor * premium_rate_bps / 10000))`. Basis
25
+ * points (1/100ths of a percent) carry zero floating-point
26
+ * surprise — the calculation is integer-only and rounds up on
27
+ * half-cent so the insurer is never short. The provider's floor
28
+ * (`premium_min_minor`) prevents a $1 declared parcel from
29
+ * computing a 0-cent premium.
30
+ *
31
+ * Insurance FSM:
32
+ *
33
+ * active --cancelInsurance--> cancelled (terminal)
34
+ * active --(post claim_window)--> expired (terminal)
35
+ *
36
+ * Claim FSM:
37
+ *
38
+ * filed --markClaimApproved--> approved (terminal)
39
+ * filed --markClaimDenied--> denied (terminal)
40
+ *
41
+ * Filing a claim requires the parent insurance to be `active` AND
42
+ * the current monotonic clock to be on/before
43
+ * `claim_window_ends_at`. Once the window elapses the insurance is
44
+ * `expired` and no new claims may be filed; existing `filed` claims
45
+ * are still resolvable (the insurer's review may take longer than
46
+ * the customer's filing window).
47
+ *
48
+ * Composes:
49
+ * - `b.guardUuid` — strict UUID gate on every order_id /
50
+ * shipment_id / customer_id / insurance_id /
51
+ * claim_id at the entry point.
52
+ * - `b.uuid.v7` — row ids (lexicographic + monotonic so
53
+ * ties on created_at still sort
54
+ * deterministically).
55
+ * - `shippingLabels` (optional) — when wired, `quoteInsurance({
56
+ * shipment_id })` uses `labelsForShipment`
57
+ * to verify the shipment has a label
58
+ * before quoting. Absent, callers may
59
+ * quote against any shipment_id (the
60
+ * operator's storefront is responsible
61
+ * for the existence check).
62
+ *
63
+ * Surface:
64
+ * - defineProvider({ code, name, premium_rate_bps,
65
+ * premium_min_minor, min_declared_value_minor,
66
+ * max_declared_value_minor, claim_window_days,
67
+ * currency, active? })
68
+ * - quoteInsurance({ provider_code, declared_value_minor,
69
+ * currency, shipment_id? })
70
+ * - purchaseInsurance({ provider_code, shipment_id, order_id,
71
+ * customer_id, declared_value_minor, currency,
72
+ * external_policy_id? })
73
+ * - fileClaim({ insurance_id, claim_type, claimed_amount_minor,
74
+ * evidence? })
75
+ * - markClaimApproved({ claim_id, payout_minor })
76
+ * - markClaimDenied({ claim_id, denial_reason })
77
+ * - getInsurance(insurance_id)
78
+ * - insurancesForOrder(order_id)
79
+ * - claimsForInsurance(insurance_id)
80
+ * - metricsForProvider({ provider_code, from, to })
81
+ *
82
+ * Storage:
83
+ * - insurance_providers, shipping_insurances, insurance_claims
84
+ * (migration `0166_shipping_insurance.sql`).
85
+ *
86
+ * Monotonic clock: operator-driven FSM transitions can land in the
87
+ * same millisecond on fast machines (markClaimApproved on a freshly
88
+ * filed claim in a test, for instance). Bumping by 1ms on a tie
89
+ * keeps the timeline strictly increasing so a sort-by-timestamp
90
+ * read returns the events in the order they were issued.
91
+ *
92
+ * @primitive shippingInsurance
93
+ * @related b.guardUuid, b.uuid, shippingLabels, orderTracking, payment
94
+ */
95
+
96
+ var bShop;
97
+ function _b() {
98
+ if (!bShop) bShop = require("./index");
99
+ return bShop.framework;
100
+ }
101
+
102
+ // ---- constants ---------------------------------------------------------
103
+
104
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
105
+ var CURRENCY_RE = /^[A-Z]{3}$/;
106
+ var EXTERNAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._\-:/]{0,127}$/;
107
+ var DENIAL_REASON_RE = /^[A-Za-z0-9][A-Za-z0-9 _.,'\-]{0,279}$/;
108
+ var MAX_NAME_LEN = 200;
109
+ var MAX_AMOUNT_MINOR = 1000000000; // $10,000,000.00 — sane upper cap
110
+ var MAX_RATE_BPS = 10000; // 100% — exclusive ceiling
111
+ var MAX_CLAIM_DAYS = 3650; // 10 years — sane upper cap
112
+ var DAY_MS = 24 * 60 * 60 * 1000;
113
+
114
+ var CLAIM_TYPES = Object.freeze(["lost", "damaged", "stolen", "not_delivered"]);
115
+ var INSURANCE_STATUSES = Object.freeze(["active", "cancelled", "expired"]);
116
+ var CLAIM_STATUSES = Object.freeze(["filed", "approved", "denied"]);
117
+
118
+ // ---- monotonic clock ---------------------------------------------------
119
+ //
120
+ // Operator-driven FSM transitions can land in the same millisecond on
121
+ // fast machines (markClaimApproved on a freshly filed claim in a test,
122
+ // for instance). Bumping by 1ms on a tie keeps the timeline strictly
123
+ // increasing so a sort-by-timestamp read returns the events in the
124
+ // order they were issued.
125
+
126
+ var _lastTs = 0;
127
+ function _now() {
128
+ var t = Date.now();
129
+ if (t <= _lastTs) { t = _lastTs + 1; }
130
+ _lastTs = t;
131
+ return t;
132
+ }
133
+
134
+ // ---- validators --------------------------------------------------------
135
+
136
+ function _uuid(s, label) {
137
+ try {
138
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
139
+ } catch (e) {
140
+ throw new TypeError("shippingInsurance: " + label + " — " + (e && e.message || "invalid UUID"));
141
+ }
142
+ }
143
+ function _code(s, label) {
144
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
145
+ throw new TypeError("shippingInsurance: " + (label || "code") +
146
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
147
+ }
148
+ return s;
149
+ }
150
+ function _name(s) {
151
+ if (typeof s !== "string" || s.length === 0 || s.length > MAX_NAME_LEN) {
152
+ throw new TypeError("shippingInsurance: name must be a non-empty string <= " + MAX_NAME_LEN + " chars");
153
+ }
154
+ return s;
155
+ }
156
+ function _currency(s) {
157
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
158
+ throw new TypeError("shippingInsurance: currency must be a 3-letter uppercase ISO-4217 code");
159
+ }
160
+ return s;
161
+ }
162
+ function _amountMinor(n, label) {
163
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_AMOUNT_MINOR) {
164
+ throw new TypeError("shippingInsurance: " + label + " must be a positive integer in [1, " + MAX_AMOUNT_MINOR + "]");
165
+ }
166
+ return n;
167
+ }
168
+ function _amountMinorNonNeg(n, label) {
169
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_AMOUNT_MINOR) {
170
+ throw new TypeError("shippingInsurance: " + label + " must be a non-negative integer in [0, " + MAX_AMOUNT_MINOR + "]");
171
+ }
172
+ return n;
173
+ }
174
+ function _rateBps(n) {
175
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > MAX_RATE_BPS) {
176
+ throw new TypeError("shippingInsurance: premium_rate_bps must be an integer in [0, " + MAX_RATE_BPS + "] (basis points)");
177
+ }
178
+ return n;
179
+ }
180
+ function _claimDays(n) {
181
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_CLAIM_DAYS) {
182
+ throw new TypeError("shippingInsurance: claim_window_days must be an integer in [1, " + MAX_CLAIM_DAYS + "]");
183
+ }
184
+ return n;
185
+ }
186
+ function _claimType(s) {
187
+ if (typeof s !== "string" || CLAIM_TYPES.indexOf(s) === -1) {
188
+ throw new TypeError("shippingInsurance: claim_type must be one of " + CLAIM_TYPES.join(", "));
189
+ }
190
+ return s;
191
+ }
192
+ function _externalPolicyId(s) {
193
+ if (typeof s !== "string" || !EXTERNAL_ID_RE.test(s)) {
194
+ throw new TypeError("shippingInsurance: external_policy_id must match /^[A-Za-z0-9][A-Za-z0-9._\\-:/]{0,127}$/");
195
+ }
196
+ return s;
197
+ }
198
+ function _denialReason(s) {
199
+ if (typeof s !== "string" || !DENIAL_REASON_RE.test(s)) {
200
+ throw new TypeError("shippingInsurance: denial_reason must match /^[A-Za-z0-9][A-Za-z0-9 _.,'\\-]{0,279}$/");
201
+ }
202
+ return s;
203
+ }
204
+ function _evidence(obj) {
205
+ if (obj == null) return {};
206
+ if (typeof obj !== "object" || Array.isArray(obj)) {
207
+ throw new TypeError("shippingInsurance: evidence must be a JSON-serialisable object");
208
+ }
209
+ try { JSON.parse(JSON.stringify(obj)); }
210
+ catch (_e) { throw new TypeError("shippingInsurance: evidence must be JSON-serialisable"); }
211
+ return obj;
212
+ }
213
+ function _epochMs(n, label) {
214
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
215
+ throw new TypeError("shippingInsurance: " + label + " must be a non-negative integer (epoch ms)");
216
+ }
217
+ return n;
218
+ }
219
+
220
+ // ---- premium math -------------------------------------------------------
221
+ //
222
+ // premium = max(premium_min_minor,
223
+ // ceil(declared_value_minor * premium_rate_bps / 10000))
224
+ // Integer-only; rounds up on half-cent so the insurer is never short.
225
+
226
+ function _computePremium(declaredValueMinor, premiumRateBps, premiumMinMinor) {
227
+ var product = declaredValueMinor * premiumRateBps;
228
+ var quotient = Math.floor(product / 10000);
229
+ if (quotient * 10000 < product) { quotient += 1; }
230
+ if (quotient < premiumMinMinor) { quotient = premiumMinMinor; }
231
+ return quotient;
232
+ }
233
+
234
+ // ---- hydration ---------------------------------------------------------
235
+
236
+ function _hydrateProvider(row) {
237
+ if (!row) return null;
238
+ return {
239
+ code: row.code,
240
+ name: row.name,
241
+ premium_rate_bps: Number(row.premium_rate_bps),
242
+ premium_min_minor: Number(row.premium_min_minor),
243
+ min_declared_value_minor: Number(row.min_declared_value_minor),
244
+ max_declared_value_minor: Number(row.max_declared_value_minor),
245
+ claim_window_days: Number(row.claim_window_days),
246
+ currency: row.currency,
247
+ active: Number(row.active) === 1,
248
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
249
+ created_at: Number(row.created_at),
250
+ updated_at: Number(row.updated_at),
251
+ };
252
+ }
253
+
254
+ function _hydrateInsurance(row) {
255
+ if (!row) return null;
256
+ return {
257
+ id: row.id,
258
+ provider_code: row.provider_code,
259
+ shipment_id: row.shipment_id,
260
+ order_id: row.order_id,
261
+ customer_id: row.customer_id,
262
+ declared_value_minor: Number(row.declared_value_minor),
263
+ premium_minor: Number(row.premium_minor),
264
+ currency: row.currency,
265
+ external_policy_id: row.external_policy_id == null ? null : row.external_policy_id,
266
+ status: row.status,
267
+ claim_window_ends_at: Number(row.claim_window_ends_at),
268
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
269
+ expired_at: row.expired_at == null ? null : Number(row.expired_at),
270
+ created_at: Number(row.created_at),
271
+ updated_at: Number(row.updated_at),
272
+ };
273
+ }
274
+
275
+ function _hydrateClaim(row) {
276
+ if (!row) return null;
277
+ var evidence;
278
+ try { evidence = JSON.parse(row.evidence_json || "{}"); }
279
+ catch (_e) { evidence = {}; }
280
+ return {
281
+ id: row.id,
282
+ insurance_id: row.insurance_id,
283
+ claim_type: row.claim_type,
284
+ status: row.status,
285
+ claimed_amount_minor: Number(row.claimed_amount_minor),
286
+ payout_minor: row.payout_minor == null ? null : Number(row.payout_minor),
287
+ denial_reason: row.denial_reason == null ? null : row.denial_reason,
288
+ evidence: evidence,
289
+ filed_at: Number(row.filed_at),
290
+ resolved_at: row.resolved_at == null ? null : Number(row.resolved_at),
291
+ updated_at: Number(row.updated_at),
292
+ };
293
+ }
294
+
295
+ // ---- factory -----------------------------------------------------------
296
+
297
+ function create(opts) {
298
+ opts = opts || {};
299
+ var query = opts.query;
300
+ if (!query) {
301
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
302
+ }
303
+
304
+ // shippingLabels is optional — when wired, `quoteInsurance({
305
+ // shipment_id })` uses `labelsForShipment` to verify the shipment
306
+ // has a label before quoting. Refuses at boot when the handle is
307
+ // supplied without the expected verb so a typo doesn't degrade
308
+ // silently to "no shipment-existence check."
309
+ var shippingLabelsHandle = opts.shippingLabels || null;
310
+ if (shippingLabelsHandle && typeof shippingLabelsHandle.labelsForShipment !== "function") {
311
+ throw new TypeError("shippingInsurance.create: opts.shippingLabels must expose a labelsForShipment(shipment_id) method");
312
+ }
313
+
314
+ async function _getProviderRow(code) {
315
+ var r = await query("SELECT * FROM insurance_providers WHERE code = ?1", [code]);
316
+ return r.rows.length ? r.rows[0] : null;
317
+ }
318
+
319
+ async function _getInsuranceRow(id) {
320
+ var r = await query("SELECT * FROM shipping_insurances WHERE id = ?1", [id]);
321
+ return r.rows.length ? r.rows[0] : null;
322
+ }
323
+
324
+ async function _getClaimRow(id) {
325
+ var r = await query("SELECT * FROM insurance_claims WHERE id = ?1", [id]);
326
+ return r.rows.length ? r.rows[0] : null;
327
+ }
328
+
329
+ async function _getInsuranceByProviderShipment(providerCode, shipmentId) {
330
+ var r = await query(
331
+ "SELECT * FROM shipping_insurances WHERE provider_code = ?1 AND shipment_id = ?2",
332
+ [providerCode, shipmentId],
333
+ );
334
+ return r.rows.length ? r.rows[0] : null;
335
+ }
336
+
337
+ // Register a third-party insurer. Upsert semantics on `code` — re-
338
+ // defining the same code updates the row in place (operator
339
+ // changes premium rate / window without invalidating prior
340
+ // insurances; existing insurances carry the snapshotted premium
341
+ // from purchase time so the customer's rate doesn't shift under
342
+ // them). Reactivates an archived provider by clearing archived_at.
343
+ async function defineProvider(input) {
344
+ if (!input || typeof input !== "object") {
345
+ throw new TypeError("shippingInsurance.defineProvider: input object required");
346
+ }
347
+ var code = _code(input.code, "code");
348
+ _name(input.name);
349
+ _rateBps(input.premium_rate_bps);
350
+ _amountMinorNonNeg(input.premium_min_minor, "premium_min_minor");
351
+ _amountMinorNonNeg(input.min_declared_value_minor, "min_declared_value_minor");
352
+ _amountMinor(input.max_declared_value_minor, "max_declared_value_minor");
353
+ if (input.max_declared_value_minor < input.min_declared_value_minor) {
354
+ throw new TypeError("shippingInsurance.defineProvider: max_declared_value_minor must be >= min_declared_value_minor");
355
+ }
356
+ _claimDays(input.claim_window_days);
357
+ _currency(input.currency);
358
+ var active = input.active === false ? 0 : 1;
359
+
360
+ var now = _now();
361
+ var existing = await _getProviderRow(code);
362
+ if (existing) {
363
+ await query(
364
+ "UPDATE insurance_providers SET name = ?1, premium_rate_bps = ?2, " +
365
+ "premium_min_minor = ?3, min_declared_value_minor = ?4, " +
366
+ "max_declared_value_minor = ?5, claim_window_days = ?6, currency = ?7, " +
367
+ "active = ?8, archived_at = NULL, updated_at = ?9 WHERE code = ?10",
368
+ [
369
+ input.name, input.premium_rate_bps, input.premium_min_minor,
370
+ input.min_declared_value_minor, input.max_declared_value_minor,
371
+ input.claim_window_days, input.currency, active, now, code,
372
+ ],
373
+ );
374
+ } else {
375
+ await query(
376
+ "INSERT INTO insurance_providers (code, name, premium_rate_bps, " +
377
+ "premium_min_minor, min_declared_value_minor, max_declared_value_minor, " +
378
+ "claim_window_days, currency, active, created_at, updated_at) " +
379
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)",
380
+ [
381
+ code, input.name, input.premium_rate_bps, input.premium_min_minor,
382
+ input.min_declared_value_minor, input.max_declared_value_minor,
383
+ input.claim_window_days, input.currency, active, now,
384
+ ],
385
+ );
386
+ }
387
+ return _hydrateProvider(await _getProviderRow(code));
388
+ }
389
+
390
+ // Quote a premium against a provider's published rate. Pure
391
+ // function over the provider row + declared value — does not
392
+ // write. Refuses on archived / inactive providers, currency
393
+ // mismatch, declared-value floor/ceiling violation. When
394
+ // shippingLabels is wired and `shipment_id` is supplied, also
395
+ // verifies the shipment has at least one label (the storefront's
396
+ // checkout flow always has minted a label before offering
397
+ // insurance; the gate catches stray callers).
398
+ async function quoteInsurance(input) {
399
+ if (!input || typeof input !== "object") {
400
+ throw new TypeError("shippingInsurance.quoteInsurance: input object required");
401
+ }
402
+ var providerCode = _code(input.provider_code, "provider_code");
403
+ _amountMinor(input.declared_value_minor, "declared_value_minor");
404
+ var currency = _currency(input.currency);
405
+
406
+ var prov = await _getProviderRow(providerCode);
407
+ if (!prov) {
408
+ throw new TypeError("shippingInsurance.quoteInsurance: provider_code " +
409
+ JSON.stringify(providerCode) + " not found");
410
+ }
411
+ if (!Number(prov.active) || prov.archived_at != null) {
412
+ throw new TypeError("shippingInsurance.quoteInsurance: provider_code " +
413
+ JSON.stringify(providerCode) + " is not active");
414
+ }
415
+ if (prov.currency !== currency) {
416
+ throw new TypeError("shippingInsurance.quoteInsurance: currency " + currency +
417
+ " does not match provider currency " + prov.currency);
418
+ }
419
+ if (input.declared_value_minor < Number(prov.min_declared_value_minor)) {
420
+ throw new TypeError("shippingInsurance.quoteInsurance: declared_value_minor " +
421
+ input.declared_value_minor + " is below provider floor " + prov.min_declared_value_minor);
422
+ }
423
+ if (input.declared_value_minor > Number(prov.max_declared_value_minor)) {
424
+ throw new TypeError("shippingInsurance.quoteInsurance: declared_value_minor " +
425
+ input.declared_value_minor + " exceeds provider ceiling " + prov.max_declared_value_minor);
426
+ }
427
+
428
+ if (input.shipment_id != null) {
429
+ var shipmentId = _uuid(input.shipment_id, "shipment_id");
430
+ if (shippingLabelsHandle) {
431
+ var labels = await shippingLabelsHandle.labelsForShipment(shipmentId);
432
+ if (!labels || !labels.length) {
433
+ throw new TypeError("shippingInsurance.quoteInsurance: shipment " +
434
+ shipmentId + " has no shipping label — mint a label before quoting insurance");
435
+ }
436
+ }
437
+ }
438
+
439
+ var premium = _computePremium(
440
+ input.declared_value_minor,
441
+ Number(prov.premium_rate_bps),
442
+ Number(prov.premium_min_minor),
443
+ );
444
+ return {
445
+ provider_code: providerCode,
446
+ declared_value_minor: input.declared_value_minor,
447
+ premium_minor: premium,
448
+ currency: currency,
449
+ claim_window_days: Number(prov.claim_window_days),
450
+ };
451
+ }
452
+
453
+ // Mint a per-shipment insurance row. Snapshots the premium at
454
+ // purchase time so a later provider-rate shift doesn't change
455
+ // what the customer pays. Refuses when an insurance for the same
456
+ // (provider, shipment) already exists (the UNIQUE constraint would
457
+ // also catch this; the app-layer check produces a friendlier error
458
+ // shape). external_policy_id is optional at purchase — the
459
+ // operator's worker may call back to attach it after the
460
+ // provider's API returns.
461
+ async function purchaseInsurance(input) {
462
+ if (!input || typeof input !== "object") {
463
+ throw new TypeError("shippingInsurance.purchaseInsurance: input object required");
464
+ }
465
+ var providerCode = _code(input.provider_code, "provider_code");
466
+ var shipmentId = _uuid(input.shipment_id, "shipment_id");
467
+ var orderId = _uuid(input.order_id, "order_id");
468
+ var customerId = _uuid(input.customer_id, "customer_id");
469
+ _amountMinor(input.declared_value_minor, "declared_value_minor");
470
+ var currency = _currency(input.currency);
471
+ var externalPolicyId = null;
472
+ if (input.external_policy_id != null) {
473
+ externalPolicyId = _externalPolicyId(input.external_policy_id);
474
+ }
475
+
476
+ var prov = await _getProviderRow(providerCode);
477
+ if (!prov) {
478
+ throw new TypeError("shippingInsurance.purchaseInsurance: provider_code " +
479
+ JSON.stringify(providerCode) + " not found");
480
+ }
481
+ if (!Number(prov.active) || prov.archived_at != null) {
482
+ throw new TypeError("shippingInsurance.purchaseInsurance: provider_code " +
483
+ JSON.stringify(providerCode) + " is not active");
484
+ }
485
+ if (prov.currency !== currency) {
486
+ throw new TypeError("shippingInsurance.purchaseInsurance: currency " + currency +
487
+ " does not match provider currency " + prov.currency);
488
+ }
489
+ if (input.declared_value_minor < Number(prov.min_declared_value_minor)) {
490
+ throw new TypeError("shippingInsurance.purchaseInsurance: declared_value_minor " +
491
+ input.declared_value_minor + " is below provider floor " + prov.min_declared_value_minor);
492
+ }
493
+ if (input.declared_value_minor > Number(prov.max_declared_value_minor)) {
494
+ throw new TypeError("shippingInsurance.purchaseInsurance: declared_value_minor " +
495
+ input.declared_value_minor + " exceeds provider ceiling " + prov.max_declared_value_minor);
496
+ }
497
+
498
+ var existing = await _getInsuranceByProviderShipment(providerCode, shipmentId);
499
+ if (existing) {
500
+ throw new TypeError("shippingInsurance.purchaseInsurance: shipment " + shipmentId +
501
+ " is already insured through " + providerCode +
502
+ " (insurance_id=" + existing.id + ")");
503
+ }
504
+
505
+ var premium = _computePremium(
506
+ input.declared_value_minor,
507
+ Number(prov.premium_rate_bps),
508
+ Number(prov.premium_min_minor),
509
+ );
510
+ var now = _now();
511
+ var windowEnds = now + Number(prov.claim_window_days) * DAY_MS;
512
+ var id = _b().uuid.v7();
513
+ await query(
514
+ "INSERT INTO shipping_insurances (id, provider_code, shipment_id, order_id, " +
515
+ "customer_id, declared_value_minor, premium_minor, currency, external_policy_id, " +
516
+ "status, claim_window_ends_at, created_at, updated_at) " +
517
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'active', ?10, ?11, ?11)",
518
+ [
519
+ id, providerCode, shipmentId, orderId, customerId,
520
+ input.declared_value_minor, premium, currency, externalPolicyId,
521
+ windowEnds, now,
522
+ ],
523
+ );
524
+ return _hydrateInsurance(await _getInsuranceRow(id));
525
+ }
526
+
527
+ // File a claim against an active insurance. Refuses on cancelled /
528
+ // expired insurances, on a claim_window that has elapsed, and on
529
+ // any claim_type outside the closed set. `evidence` is a JSON
530
+ // object captured at filing time — typically photo URLs, carrier
531
+ // scan reports, signature attestations; the primitive only gates
532
+ // JSON round-trip, not the body shape (insurers vary).
533
+ async function fileClaim(input) {
534
+ if (!input || typeof input !== "object") {
535
+ throw new TypeError("shippingInsurance.fileClaim: input object required");
536
+ }
537
+ var insuranceId = _uuid(input.insurance_id, "insurance_id");
538
+ var claimType = _claimType(input.claim_type);
539
+ _amountMinor(input.claimed_amount_minor, "claimed_amount_minor");
540
+ var evidence = _evidence(input.evidence);
541
+
542
+ var ins = await _getInsuranceRow(insuranceId);
543
+ if (!ins) {
544
+ throw new TypeError("shippingInsurance.fileClaim: insurance " + insuranceId + " not found");
545
+ }
546
+ var now = _now();
547
+ // Auto-expire on read — a claim_window that elapsed without any
548
+ // operator activity should land as `expired` rather than silently
549
+ // accept a stale claim. We flip the status here so the refusal
550
+ // message is accurate; the (terminal) expired status is the same
551
+ // a separate sweeper would produce.
552
+ if (ins.status === "active" && now > Number(ins.claim_window_ends_at)) {
553
+ await query(
554
+ "UPDATE shipping_insurances SET status = 'expired', expired_at = ?1, updated_at = ?1 WHERE id = ?2",
555
+ [now, insuranceId],
556
+ );
557
+ ins = await _getInsuranceRow(insuranceId);
558
+ }
559
+ if (ins.status !== "active") {
560
+ throw new TypeError("shippingInsurance.fileClaim: insurance " + insuranceId +
561
+ " is " + ins.status + ", only active insurances accept new claims");
562
+ }
563
+ if (input.claimed_amount_minor > Number(ins.declared_value_minor)) {
564
+ throw new TypeError("shippingInsurance.fileClaim: claimed_amount_minor " +
565
+ input.claimed_amount_minor + " exceeds declared_value_minor " +
566
+ ins.declared_value_minor);
567
+ }
568
+
569
+ var id = _b().uuid.v7();
570
+ await query(
571
+ "INSERT INTO insurance_claims (id, insurance_id, claim_type, status, " +
572
+ "claimed_amount_minor, evidence_json, filed_at, updated_at) " +
573
+ "VALUES (?1, ?2, ?3, 'filed', ?4, ?5, ?6, ?6)",
574
+ [id, insuranceId, claimType, input.claimed_amount_minor, JSON.stringify(evidence), now],
575
+ );
576
+ return _hydrateClaim(await _getClaimRow(id));
577
+ }
578
+
579
+ // filed -> approved. Stamps payout_minor (which may differ from
580
+ // the customer's claimed_amount_minor — the insurer's adjuster may
581
+ // pay less than the requested amount per the provider's depreciation
582
+ // schedule). Refuses on already-terminal rows.
583
+ async function markClaimApproved(input) {
584
+ if (!input || typeof input !== "object") {
585
+ throw new TypeError("shippingInsurance.markClaimApproved: input object required");
586
+ }
587
+ var claimId = _uuid(input.claim_id, "claim_id");
588
+ _amountMinor(input.payout_minor, "payout_minor");
589
+
590
+ var claim = await _getClaimRow(claimId);
591
+ if (!claim) {
592
+ throw new TypeError("shippingInsurance.markClaimApproved: claim " + claimId + " not found");
593
+ }
594
+ if (claim.status !== "filed") {
595
+ throw new TypeError("shippingInsurance.markClaimApproved: claim " + claimId +
596
+ " is " + claim.status + ", only filed claims can move to approved");
597
+ }
598
+ var ins = await _getInsuranceRow(claim.insurance_id);
599
+ if (input.payout_minor > Number(ins.declared_value_minor)) {
600
+ throw new TypeError("shippingInsurance.markClaimApproved: payout_minor " +
601
+ input.payout_minor + " exceeds declared_value_minor " +
602
+ ins.declared_value_minor + " on the parent insurance");
603
+ }
604
+ var now = _now();
605
+ await query(
606
+ "UPDATE insurance_claims SET status = 'approved', payout_minor = ?1, " +
607
+ "resolved_at = ?2, updated_at = ?2 WHERE id = ?3",
608
+ [input.payout_minor, now, claimId],
609
+ );
610
+ return _hydrateClaim(await _getClaimRow(claimId));
611
+ }
612
+
613
+ // filed -> denied. Captures the operator-supplied denial_reason
614
+ // (insurer-facing prose the customer reads on the resolution
615
+ // notice). Refuses on already-terminal rows.
616
+ async function markClaimDenied(input) {
617
+ if (!input || typeof input !== "object") {
618
+ throw new TypeError("shippingInsurance.markClaimDenied: input object required");
619
+ }
620
+ var claimId = _uuid(input.claim_id, "claim_id");
621
+ var denialReason = _denialReason(input.denial_reason);
622
+
623
+ var claim = await _getClaimRow(claimId);
624
+ if (!claim) {
625
+ throw new TypeError("shippingInsurance.markClaimDenied: claim " + claimId + " not found");
626
+ }
627
+ if (claim.status !== "filed") {
628
+ throw new TypeError("shippingInsurance.markClaimDenied: claim " + claimId +
629
+ " is " + claim.status + ", only filed claims can move to denied");
630
+ }
631
+ var now = _now();
632
+ await query(
633
+ "UPDATE insurance_claims SET status = 'denied', denial_reason = ?1, " +
634
+ "resolved_at = ?2, updated_at = ?2 WHERE id = ?3",
635
+ [denialReason, now, claimId],
636
+ );
637
+ return _hydrateClaim(await _getClaimRow(claimId));
638
+ }
639
+
640
+ async function getInsurance(insuranceId) {
641
+ var id = _uuid(insuranceId, "insurance_id");
642
+ return _hydrateInsurance(await _getInsuranceRow(id));
643
+ }
644
+
645
+ // Per-order list. An order may fan out to multiple shipments and
646
+ // multiple insurances per shipment (belt-and-braces overlays
647
+ // through different providers), so this read is naturally a list.
648
+ // Ordered by created_at ASC, id ASC for deterministic iteration.
649
+ async function insurancesForOrder(orderId) {
650
+ var id = _uuid(orderId, "order_id");
651
+ var rows = (await query(
652
+ "SELECT * FROM shipping_insurances WHERE order_id = ?1 ORDER BY created_at ASC, id ASC",
653
+ [id],
654
+ )).rows;
655
+ return rows.map(_hydrateInsurance);
656
+ }
657
+
658
+ async function claimsForInsurance(insuranceId) {
659
+ var id = _uuid(insuranceId, "insurance_id");
660
+ var rows = (await query(
661
+ "SELECT * FROM insurance_claims WHERE insurance_id = ?1 ORDER BY filed_at ASC, id ASC",
662
+ [id],
663
+ )).rows;
664
+ return rows.map(_hydrateClaim);
665
+ }
666
+
667
+ // Provider-level rollup over [from, to]. Counts active /
668
+ // cancelled / expired insurances, total premium collected, claim
669
+ // counts by status, and total approved payout. Operator dashboard
670
+ // "Shipsurance performance this month" reads from here.
671
+ async function metricsForProvider(input) {
672
+ if (!input || typeof input !== "object") {
673
+ throw new TypeError("shippingInsurance.metricsForProvider: input object required");
674
+ }
675
+ var providerCode = _code(input.provider_code, "provider_code");
676
+ var from = _epochMs(input.from, "from");
677
+ var to = _epochMs(input.to, "to");
678
+ if (from > to) {
679
+ throw new TypeError("shippingInsurance.metricsForProvider: from must be <= to");
680
+ }
681
+ var insRows = (await query(
682
+ "SELECT status, premium_minor FROM shipping_insurances " +
683
+ "WHERE provider_code = ?1 AND created_at >= ?2 AND created_at <= ?3",
684
+ [providerCode, from, to],
685
+ )).rows;
686
+ var activeCount = 0;
687
+ var cancelledCount = 0;
688
+ var expiredCount = 0;
689
+ var totalPremium = 0;
690
+ for (var i = 0; i < insRows.length; i += 1) {
691
+ var r = insRows[i];
692
+ totalPremium += Number(r.premium_minor) || 0;
693
+ if (r.status === "active") { activeCount += 1; }
694
+ else if (r.status === "cancelled") { cancelledCount += 1; }
695
+ else if (r.status === "expired") { expiredCount += 1; }
696
+ }
697
+
698
+ var claimRows = (await query(
699
+ "SELECT c.status AS status, c.payout_minor AS payout_minor " +
700
+ "FROM insurance_claims c " +
701
+ "JOIN shipping_insurances i ON i.id = c.insurance_id " +
702
+ "WHERE i.provider_code = ?1 AND c.filed_at >= ?2 AND c.filed_at <= ?3",
703
+ [providerCode, from, to],
704
+ )).rows;
705
+ var filedCount = 0;
706
+ var approvedCount = 0;
707
+ var deniedCount = 0;
708
+ var totalPayout = 0;
709
+ for (var j = 0; j < claimRows.length; j += 1) {
710
+ var c = claimRows[j];
711
+ if (c.status === "filed") { filedCount += 1; }
712
+ else if (c.status === "approved") { approvedCount += 1; totalPayout += Number(c.payout_minor) || 0; }
713
+ else if (c.status === "denied") { deniedCount += 1; }
714
+ }
715
+
716
+ return {
717
+ provider_code: providerCode,
718
+ from: from,
719
+ to: to,
720
+ active_count: activeCount,
721
+ cancelled_count: cancelledCount,
722
+ expired_count: expiredCount,
723
+ total_premium_minor: totalPremium,
724
+ claims_filed_count: filedCount,
725
+ claims_approved_count: approvedCount,
726
+ claims_denied_count: deniedCount,
727
+ total_payout_minor: totalPayout,
728
+ };
729
+ }
730
+
731
+ return {
732
+ CLAIM_TYPES: CLAIM_TYPES,
733
+ INSURANCE_STATUSES: INSURANCE_STATUSES,
734
+ CLAIM_STATUSES: CLAIM_STATUSES,
735
+
736
+ defineProvider: defineProvider,
737
+ quoteInsurance: quoteInsurance,
738
+ purchaseInsurance: purchaseInsurance,
739
+ fileClaim: fileClaim,
740
+ markClaimApproved: markClaimApproved,
741
+ markClaimDenied: markClaimDenied,
742
+ getInsurance: getInsurance,
743
+ insurancesForOrder: insurancesForOrder,
744
+ claimsForInsurance: claimsForInsurance,
745
+ metricsForProvider: metricsForProvider,
746
+ };
747
+ }
748
+
749
+ module.exports = {
750
+ create: create,
751
+ CLAIM_TYPES: CLAIM_TYPES,
752
+ INSURANCE_STATUSES: INSURANCE_STATUSES,
753
+ CLAIM_STATUSES: CLAIM_STATUSES,
754
+ MAX_AMOUNT_MINOR: MAX_AMOUNT_MINOR,
755
+ MAX_RATE_BPS: MAX_RATE_BPS,
756
+ MAX_CLAIM_DAYS: MAX_CLAIM_DAYS,
757
+ };