@blamejs/blamejs-shop 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1228 -15
- package/lib/asset-manifest.json +5 -5
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +27 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.24",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
|
-
"integrity": "sha384-
|
|
6
|
-
"fingerprinted": "css/admin.
|
|
5
|
+
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
6
|
+
"fingerprinted": "css/admin.6941d5151488a7c1.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-z6x2cNhNfsxxs7OZi3ZIg84HAcsJIUyhH4RjZwuHCdf03Rr68dm9zxciLgThkGLh",
|
|
10
|
+
"fingerprinted": "css/main.cf0a6763075ece73.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/customers.js
CHANGED
|
@@ -167,6 +167,23 @@ function create(opts) {
|
|
|
167
167
|
return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Monotonic upsert of the per-customer session-revocation boundary. The
|
|
171
|
+
// INSERT-OR-conflict keeps the LATER of the existing and incoming value
|
|
172
|
+
// so two concurrent revokes (erasure + a passkey-revoke firing together)
|
|
173
|
+
// can never roll the boundary backwards and resurrect a cookie one of
|
|
174
|
+
// them meant to kill. `MAX(...)` on the conflict path is the conservation
|
|
175
|
+
// guard; `excluded` references the row the INSERT tried to add.
|
|
176
|
+
async function _bumpSessionRevocation(id, at) {
|
|
177
|
+
await query(
|
|
178
|
+
"INSERT INTO customer_session_revocations (customer_id, sessions_valid_from, updated_at) " +
|
|
179
|
+
"VALUES (?1, ?2, ?2) " +
|
|
180
|
+
"ON CONFLICT(customer_id) DO UPDATE SET " +
|
|
181
|
+
"sessions_valid_from = MAX(customer_session_revocations.sessions_valid_from, excluded.sessions_valid_from), " +
|
|
182
|
+
"updated_at = excluded.updated_at",
|
|
183
|
+
[id, at],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
170
187
|
var api = {
|
|
171
188
|
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
172
189
|
|
|
@@ -600,6 +617,12 @@ function create(opts) {
|
|
|
600
617
|
);
|
|
601
618
|
emailHashCleared = Number((upd && upd.rowCount) || 0);
|
|
602
619
|
}
|
|
620
|
+
// Terminate every live sealed auth cookie for the erased account.
|
|
621
|
+
// Deleting the durable credentials stops a NEW session from being
|
|
622
|
+
// minted, but a cookie issued before erasure is stateless and stays
|
|
623
|
+
// valid for its 14-day TTL — so the anonymized account remains usable
|
|
624
|
+
// until then unless we move the revocation boundary forward now.
|
|
625
|
+
await _bumpSessionRevocation(id, ts);
|
|
603
626
|
return {
|
|
604
627
|
passkeys: Number((pkDel && pkDel.rowCount) || 0),
|
|
605
628
|
oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
|
|
@@ -607,6 +630,36 @@ function create(opts) {
|
|
|
607
630
|
};
|
|
608
631
|
},
|
|
609
632
|
|
|
633
|
+
// Move the customer's session-revocation boundary to `at` (default
|
|
634
|
+
// now). Every sealed auth cookie whose `iat` predates the boundary —
|
|
635
|
+
// and every pre-`iat` cookie once a boundary exists — fails the
|
|
636
|
+
// revocation check on its next authenticated request and signs out.
|
|
637
|
+
// Idempotent and monotonic: a later bump only ever moves the boundary
|
|
638
|
+
// forward, so two concurrent revokes can't roll it backwards. Used by
|
|
639
|
+
// erasure (above), passkey-revoke, and explicit sign-out.
|
|
640
|
+
revokeSessionsForCustomer: async function (id, at) {
|
|
641
|
+
_uuid(id, "customer id");
|
|
642
|
+
var boundary = (at == null) ? _now() : at;
|
|
643
|
+
if (!Number.isInteger(boundary) || boundary < 0) {
|
|
644
|
+
throw new TypeError("customers.revokeSessionsForCustomer: at must be a non-negative integer epoch-ms or null");
|
|
645
|
+
}
|
|
646
|
+
await _bumpSessionRevocation(id, boundary);
|
|
647
|
+
return { customer_id: id, sessions_valid_from: boundary };
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// The customer's session-revocation boundary (epoch-ms), or 0 when no
|
|
651
|
+
// revocation has ever been recorded (the default-allow state). The
|
|
652
|
+
// auth-cookie reader compares the cookie's `iat` against this: a cookie
|
|
653
|
+
// is live when the boundary is 0, or when its `iat` is >= the boundary.
|
|
654
|
+
sessionsValidFrom: async function (id) {
|
|
655
|
+
_uuid(id, "customer id");
|
|
656
|
+
var row = (await query(
|
|
657
|
+
"SELECT sessions_valid_from FROM customer_session_revocations WHERE customer_id = ?1 LIMIT 1",
|
|
658
|
+
[id],
|
|
659
|
+
)).rows[0];
|
|
660
|
+
return row ? Number(row.sessions_valid_from) : 0;
|
|
661
|
+
},
|
|
662
|
+
|
|
610
663
|
// Mutate a customer's editable profile fields. v1 covers display_name
|
|
611
664
|
// only — the one field a customer can safely change without a
|
|
612
665
|
// verification round trip.
|
package/lib/cycle-counting.js
CHANGED
|
@@ -576,6 +576,25 @@ function create(opts) {
|
|
|
576
576
|
throw new TypeError("cycle-counting.finalizeCount: count is " + header.status +
|
|
577
577
|
", only scheduled or in_progress counts can be finalized");
|
|
578
578
|
}
|
|
579
|
+
// Claim the (scheduled|in_progress) -> finalized transition atomically
|
|
580
|
+
// BEFORE computing variances or applying adjustments. Two concurrent
|
|
581
|
+
// finalizes both pass the read above, but only one UPDATE matches the
|
|
582
|
+
// expected status; the loser refuses, so the per-shelf adjustments are
|
|
583
|
+
// applied exactly once. (Without this guard both calls would write the
|
|
584
|
+
// variance adjustments through inventoryLocations.adjustStock, double-
|
|
585
|
+
// applying.) The final aggregate counts are stamped below once the loop
|
|
586
|
+
// has run.
|
|
587
|
+
var ts = _now();
|
|
588
|
+
var claim = await query(
|
|
589
|
+
"UPDATE cycle_counts SET status = 'finalized', finalized_at = ?1 " +
|
|
590
|
+
"WHERE slug = ?2 AND status IN ('scheduled', 'in_progress')",
|
|
591
|
+
[ts, input.slug],
|
|
592
|
+
);
|
|
593
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
594
|
+
throw new TypeError("cycle-counting.finalizeCount: count " +
|
|
595
|
+
JSON.stringify(input.slug) + " is no longer scheduled or in_progress " +
|
|
596
|
+
"(finalized by a concurrent call)");
|
|
597
|
+
}
|
|
579
598
|
var lines = await _getLines(input.slug);
|
|
580
599
|
var varianceCount = 0;
|
|
581
600
|
var varianceValueMinor = 0;
|
|
@@ -616,11 +635,12 @@ function create(opts) {
|
|
|
616
635
|
}
|
|
617
636
|
}
|
|
618
637
|
}
|
|
619
|
-
|
|
638
|
+
// Status + finalized_at were already claimed above; stamp the computed
|
|
639
|
+
// aggregate variance totals onto the (now-finalized) header.
|
|
620
640
|
await query(
|
|
621
|
-
"UPDATE cycle_counts SET
|
|
622
|
-
"
|
|
623
|
-
[varianceCount, varianceValueMinor,
|
|
641
|
+
"UPDATE cycle_counts SET variance_count = ?1, variance_value_minor = ?2 " +
|
|
642
|
+
"WHERE slug = ?3",
|
|
643
|
+
[varianceCount, varianceValueMinor, input.slug],
|
|
624
644
|
);
|
|
625
645
|
return {
|
|
626
646
|
variance_count: varianceCount,
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -72,6 +72,13 @@ var b = require("./vendor/blamejs");
|
|
|
72
72
|
var KINDS = ["credit", "debit", "expire"];
|
|
73
73
|
var SOURCES = ["purchase", "refund_to_giftcard", "promotional", "manual"];
|
|
74
74
|
|
|
75
|
+
var SHA3_512_HEX_LEN = 128;
|
|
76
|
+
// Genesis anchor for a card's first ledger row. Each gift card is its own
|
|
77
|
+
// independent chain — the first row keyed by a `gift_card_id` links to
|
|
78
|
+
// ZERO, every subsequent row links to the prior row's row_hash for the
|
|
79
|
+
// SAME card.
|
|
80
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
81
|
+
|
|
75
82
|
var MAX_SOURCE_REF_LEN = 128;
|
|
76
83
|
// Source_ref / reason are short correlation handles (originating
|
|
77
84
|
// order id, refund handle, campaign code, operator note). Refuse
|
|
@@ -130,6 +137,44 @@ function _epochMs(ts, label) {
|
|
|
130
137
|
|
|
131
138
|
function _now() { return Date.now(); }
|
|
132
139
|
|
|
140
|
+
// ---- chain hashing ------------------------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// Mirrors operator-audit-log's composition exactly: the preimage is
|
|
143
|
+
//
|
|
144
|
+
// row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
|
|
145
|
+
//
|
|
146
|
+
// where row-fields is every persisted ledger column except prev_hash +
|
|
147
|
+
// row_hash. `b.auditChain.canonicalize` (RFC 8785 walker, sorted keys,
|
|
148
|
+
// hash columns excluded) makes the preimage byte-identical across
|
|
149
|
+
// deployments + node versions. We deliberately do NOT use
|
|
150
|
+
// b.auditChain.computeRowHash / verifyChain — those expect camelCase
|
|
151
|
+
// rowHash / monotonicCounter / nonce columns this snake_case schema does
|
|
152
|
+
// not carry; composing canonicalize + sha3Hash directly is the
|
|
153
|
+
// snake_case-compatible path.
|
|
154
|
+
|
|
155
|
+
function _rowFieldsForHash(row) {
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
gift_card_id: row.gift_card_id,
|
|
159
|
+
kind: row.kind,
|
|
160
|
+
amount_minor: Number(row.amount_minor),
|
|
161
|
+
source: row.source == null ? null : row.source,
|
|
162
|
+
source_ref: row.source_ref == null ? null : row.source_ref,
|
|
163
|
+
order_id: row.order_id == null ? null : row.order_id,
|
|
164
|
+
balance_after_minor: Number(row.balance_after_minor),
|
|
165
|
+
occurred_at: Number(row.occurred_at),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _computeRowHash(prevHash, rowFields) {
|
|
170
|
+
var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
|
|
171
|
+
var preimage = Buffer.concat([
|
|
172
|
+
Buffer.from(prevHash, "hex"),
|
|
173
|
+
Buffer.from(canonical, "utf8"),
|
|
174
|
+
]);
|
|
175
|
+
return b.crypto.sha3Hash(preimage);
|
|
176
|
+
}
|
|
177
|
+
|
|
133
178
|
// ---- factory ------------------------------------------------------------
|
|
134
179
|
|
|
135
180
|
function create(opts) {
|
|
@@ -158,12 +203,21 @@ function create(opts) {
|
|
|
158
203
|
// `_resolveOccurredAt`).
|
|
159
204
|
async function _readLatest(giftCardId) {
|
|
160
205
|
var r = await query(
|
|
161
|
-
"SELECT balance_after_minor, occurred_at FROM gift_card_ledger " +
|
|
162
|
-
"WHERE gift_card_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
206
|
+
"SELECT id, balance_after_minor, occurred_at, row_hash FROM gift_card_ledger " +
|
|
207
|
+
"WHERE gift_card_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
163
208
|
[giftCardId],
|
|
164
209
|
);
|
|
165
|
-
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
166
|
-
return {
|
|
210
|
+
if (!r.rows.length) return { id: null, balance: 0, occurred_at: null, row_hash: ZERO_HASH };
|
|
211
|
+
return {
|
|
212
|
+
id: r.rows[0].id,
|
|
213
|
+
balance: r.rows[0].balance_after_minor,
|
|
214
|
+
occurred_at: r.rows[0].occurred_at,
|
|
215
|
+
// A legacy pre-chain row carries NULL row_hash — chain forward from
|
|
216
|
+
// ZERO so the first hashed row anchors the chain; verifyChain treats
|
|
217
|
+
// the unhashed legacy prefix as unverifiable (NULL row_hash break)
|
|
218
|
+
// rather than silently trusting it.
|
|
219
|
+
row_hash: r.rows[0].row_hash == null ? ZERO_HASH : r.rows[0].row_hash,
|
|
220
|
+
};
|
|
167
221
|
}
|
|
168
222
|
|
|
169
223
|
async function _currentBalance(giftCardId) {
|
|
@@ -187,13 +241,30 @@ function create(opts) {
|
|
|
187
241
|
return latestTs + 1;
|
|
188
242
|
}
|
|
189
243
|
|
|
190
|
-
|
|
244
|
+
// credit() + expire() write through here: they already read the prior
|
|
245
|
+
// row (balance + occurred_at), so chaining is a straight read-then-write
|
|
246
|
+
// — `prevHash` is that prior row's row_hash, and row_hash is computed
|
|
247
|
+
// over the fully-resolved field tuple before the INSERT. debit() does
|
|
248
|
+
// NOT use this path: its overdraft guard must stay a single atomic
|
|
249
|
+
// statement, so it chains separately (see below).
|
|
250
|
+
async function _writeRow(giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash) {
|
|
191
251
|
var id = b.uuid.v7();
|
|
252
|
+
var rowHash = _computeRowHash(prevHash, {
|
|
253
|
+
id: id,
|
|
254
|
+
gift_card_id: giftCardId,
|
|
255
|
+
kind: kind,
|
|
256
|
+
amount_minor: amountMinor,
|
|
257
|
+
source: source,
|
|
258
|
+
source_ref: sourceRef,
|
|
259
|
+
order_id: orderId,
|
|
260
|
+
balance_after_minor: balanceAfter,
|
|
261
|
+
occurred_at: ts,
|
|
262
|
+
});
|
|
192
263
|
await query(
|
|
193
264
|
"INSERT INTO gift_card_ledger " +
|
|
194
|
-
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
|
|
195
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
196
|
-
[id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts],
|
|
265
|
+
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at, prev_hash, row_hash) " +
|
|
266
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
267
|
+
[id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash, rowHash],
|
|
197
268
|
);
|
|
198
269
|
return id;
|
|
199
270
|
}
|
|
@@ -216,7 +287,7 @@ function create(opts) {
|
|
|
216
287
|
var latest = await _readLatest(giftCardId);
|
|
217
288
|
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
218
289
|
var after = latest.balance + amount;
|
|
219
|
-
var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts);
|
|
290
|
+
var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts, latest.row_hash);
|
|
220
291
|
|
|
221
292
|
return {
|
|
222
293
|
id: id,
|
|
@@ -334,7 +405,7 @@ function create(opts) {
|
|
|
334
405
|
}
|
|
335
406
|
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
336
407
|
var after = latest.balance - toBurn;
|
|
337
|
-
var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts);
|
|
408
|
+
var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts, latest.row_hash);
|
|
338
409
|
|
|
339
410
|
return {
|
|
340
411
|
id: id,
|
package/lib/giftcards.js
CHANGED
|
@@ -406,6 +406,94 @@ function create(opts) {
|
|
|
406
406
|
return reversed;
|
|
407
407
|
},
|
|
408
408
|
|
|
409
|
+
// Credit a card's spend back PROPORTIONALLY to a partial refund — the
|
|
410
|
+
// refund-by-amount counterpart to reverseRedemption's all-or-nothing
|
|
411
|
+
// (cancel) release. A full refund (cancel / payment-failed) returns the
|
|
412
|
+
// whole spend; a partial refund must return only the share the refund
|
|
413
|
+
// covers, or a 10%-refunded order would re-mint the entire gift-card
|
|
414
|
+
// value as spendable while the customer keeps 90% of the goods.
|
|
415
|
+
//
|
|
416
|
+
// For each redemption against the order the target cumulative reversal is
|
|
417
|
+
// floor(amount_minor * refunded_minor / order_total_minor), clamped to
|
|
418
|
+
// amount_minor so rounding + a refund that exceeds the recorded total can
|
|
419
|
+
// never credit back more than the card actually paid. The credit is the
|
|
420
|
+
// delta between that target and what's already been reversed
|
|
421
|
+
// (reversed_minor), so a partial-then-final refund sequence converges
|
|
422
|
+
// exactly on the spend without ever over-crediting. reversed_minor is
|
|
423
|
+
// advanced under a guarded UPDATE keyed on the row's live value, so two
|
|
424
|
+
// concurrent reversals can't both credit the same slice (the second sees
|
|
425
|
+
// its expected-prior value no longer matches and re-derives the delta as
|
|
426
|
+
// zero). A redemption that reaches full reversal also stamps reversed_at
|
|
427
|
+
// so the existing (binary) reverseRedemption short-circuits it.
|
|
428
|
+
//
|
|
429
|
+
// Returns the list of redemptions that received a non-zero credit, each
|
|
430
|
+
// with the minor-units credited this call — the caller writes the
|
|
431
|
+
// matching ledger credits. An order that used no gift card, or one
|
|
432
|
+
// already reversed up to the requested proportion, returns [].
|
|
433
|
+
reverseRedemptionProRata: async function (orderId, input) {
|
|
434
|
+
_uuid(orderId, "order_id");
|
|
435
|
+
input = input || {};
|
|
436
|
+
var refundedMinor = input.refunded_minor;
|
|
437
|
+
var orderTotalMinor = input.order_total_minor;
|
|
438
|
+
if (!Number.isInteger(refundedMinor) || refundedMinor < 0) {
|
|
439
|
+
throw new TypeError("giftcards.reverseRedemptionProRata: refunded_minor must be a non-negative integer (minor units)");
|
|
440
|
+
}
|
|
441
|
+
if (!Number.isInteger(orderTotalMinor) || orderTotalMinor <= 0) {
|
|
442
|
+
throw new TypeError("giftcards.reverseRedemptionProRata: order_total_minor must be a positive integer (minor units)");
|
|
443
|
+
}
|
|
444
|
+
// A refund can't exceed the order total; clamp the fraction at 1 so a
|
|
445
|
+
// recorded over-refund (rounding at the provider, a manual adjustment)
|
|
446
|
+
// never targets more than the full spend.
|
|
447
|
+
var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
|
|
448
|
+
var rows = (await query(
|
|
449
|
+
"SELECT id, giftcard_id, amount_minor, reversed_minor FROM giftcard_redemptions " +
|
|
450
|
+
"WHERE order_id = ?1",
|
|
451
|
+
[orderId],
|
|
452
|
+
)).rows;
|
|
453
|
+
var credited = [];
|
|
454
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
455
|
+
var red = rows[i];
|
|
456
|
+
var amount = Number(red.amount_minor);
|
|
457
|
+
var already = Number(red.reversed_minor || 0);
|
|
458
|
+
// Proportional target in minor units. floor so we never round UP
|
|
459
|
+
// into crediting a fraction the refund didn't cover; clamp to the
|
|
460
|
+
// full spend so cumulative reversals can't exceed it.
|
|
461
|
+
var target = Math.floor((amount * effRefunded) / orderTotalMinor);
|
|
462
|
+
if (target > amount) target = amount;
|
|
463
|
+
var delta = target - already;
|
|
464
|
+
if (delta <= 0) continue; // already reversed to (or past) this proportion
|
|
465
|
+
var ts = _now();
|
|
466
|
+
var nowFull = target >= amount;
|
|
467
|
+
// Claim the slice: advance reversed_minor from its expected prior
|
|
468
|
+
// value so a concurrent reversal can't double-credit. Stamp
|
|
469
|
+
// reversed_at only when the redemption is now fully reversed (so the
|
|
470
|
+
// binary reverseRedemption treats it as done).
|
|
471
|
+
var claim = await query(
|
|
472
|
+
"UPDATE giftcard_redemptions SET reversed_minor = ?1, " +
|
|
473
|
+
"reversed_at = CASE WHEN ?2 = 1 THEN ?3 ELSE reversed_at END " +
|
|
474
|
+
"WHERE id = ?4 AND reversed_minor = ?5",
|
|
475
|
+
[target, nowFull ? 1 : 0, ts, red.id, already],
|
|
476
|
+
);
|
|
477
|
+
if (Number(claim.rowCount || 0) === 0) continue; // lost the claim to a concurrent reversal
|
|
478
|
+
// Restore the spendable balance on the card row. Capped at the card's
|
|
479
|
+
// issued_minor by the schema CHECK; the cumulative reversed_minor is
|
|
480
|
+
// bounded by amount_minor, so it can't exceed the face value.
|
|
481
|
+
// Reactivate a card drained to `redeemed` — it carries balance again.
|
|
482
|
+
await query(
|
|
483
|
+
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
484
|
+
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
485
|
+
"updated_at = ?2 WHERE id = ?3",
|
|
486
|
+
[delta, ts, red.giftcard_id],
|
|
487
|
+
);
|
|
488
|
+
credited.push({
|
|
489
|
+
redemption_id: red.id,
|
|
490
|
+
gift_card_id: red.giftcard_id,
|
|
491
|
+
amount_minor: delta,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return credited;
|
|
495
|
+
},
|
|
496
|
+
|
|
409
497
|
"void": async function (id, opts2) {
|
|
410
498
|
opts2 = opts2 || {};
|
|
411
499
|
_uuid(id, "giftcard id");
|
|
@@ -369,25 +369,44 @@ function create(opts) {
|
|
|
369
369
|
" has no location_code — pin a location before commit");
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
// the
|
|
378
|
-
await locations.adjustStock({
|
|
379
|
-
sku: existing.sku,
|
|
380
|
-
location_code: existing.location_code,
|
|
381
|
-
delta: -existing.quantity,
|
|
382
|
-
reason: "hold-commit:" + existing.id + " order:" + input.order_id,
|
|
383
|
-
});
|
|
384
|
-
|
|
372
|
+
// Claim the held -> committed transition atomically BEFORE debiting the
|
|
373
|
+
// shelf. Two concurrent commits both pass the status read above, but only
|
|
374
|
+
// one UPDATE matches `status = 'held'`; the loser sees rowCount 0 and
|
|
375
|
+
// refuses, so the committed shelf is debited exactly once. (Without this
|
|
376
|
+
// guard both calls would debit the shelf, oversell.) The committed_order_id
|
|
377
|
+
// also lands now so the row's order pointer is set before the debit.
|
|
385
378
|
var ts = _monotonicTs();
|
|
386
|
-
await query(
|
|
379
|
+
var claim = await query(
|
|
387
380
|
"UPDATE inventory_holds SET status = 'committed', committed_at = ?1, " +
|
|
388
381
|
"committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
|
|
389
382
|
[ts, input.order_id, input.hold_id],
|
|
390
383
|
);
|
|
384
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
385
|
+
throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
|
|
386
|
+
" is no longer held (committed by a concurrent call)");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Debit the committed shelf through inventoryLocations.adjustStock. If the
|
|
390
|
+
// shelf write fails (insufficient stock, unknown location), release the
|
|
391
|
+
// claim back to 'held' so a retry or a manual release decides next — the
|
|
392
|
+
// hold must not strand in 'committed' with no shelf movement behind it.
|
|
393
|
+
try {
|
|
394
|
+
await locations.adjustStock({
|
|
395
|
+
sku: existing.sku,
|
|
396
|
+
location_code: existing.location_code,
|
|
397
|
+
delta: -existing.quantity,
|
|
398
|
+
reason: "hold-commit:" + existing.id + " order:" + input.order_id,
|
|
399
|
+
});
|
|
400
|
+
} catch (e) {
|
|
401
|
+
try {
|
|
402
|
+
await query(
|
|
403
|
+
"UPDATE inventory_holds SET status = 'held', committed_at = NULL, " +
|
|
404
|
+
"committed_order_id = NULL WHERE id = ?1 AND status = 'committed'",
|
|
405
|
+
[input.hold_id],
|
|
406
|
+
);
|
|
407
|
+
} catch (_e2) { /* drop-silent — the adjustStock error is the caller's signal */ }
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
391
410
|
return _shapeHold(await _getHoldRow(input.hold_id));
|
|
392
411
|
}
|
|
393
412
|
|
package/lib/inventory-receive.js
CHANGED
|
@@ -78,6 +78,43 @@ var MAX_LIST_LIMIT = 200;
|
|
|
78
78
|
var RECEIPT_STATUSES = Object.freeze(["pending", "applied", "reversed"]);
|
|
79
79
|
var RECEIPT_ORDER_KEY = ["received_at:desc", "id:desc"];
|
|
80
80
|
|
|
81
|
+
// The receipt lifecycle, modelled on b.fsm — the same composition the order /
|
|
82
|
+
// quote / stock-transfer primitives use. The FSM is the single source of truth
|
|
83
|
+
// for which (status, event) pairs are legal:
|
|
84
|
+
//
|
|
85
|
+
// apply pending -> applied (apply; restocks every line)
|
|
86
|
+
// reverse applied -> reversed (reverse; decrements every line back out)
|
|
87
|
+
//
|
|
88
|
+
// Terminal (reversed) declares no outgoing edge, so the machine itself refuses
|
|
89
|
+
// a reverse-after-reverse. `apply` on an already-'applied' receipt is a
|
|
90
|
+
// documented IDEMPOTENT no-op (returns { applied_count: 0 }) — apply() short-
|
|
91
|
+
// circuits to that no-op before consulting the machine, preserving the
|
|
92
|
+
// replay-safe contract callers depend on.
|
|
93
|
+
var RECEIPT_TRANSITIONS = Object.freeze([
|
|
94
|
+
{ from: "pending", to: "applied", on: "apply" },
|
|
95
|
+
{ from: "applied", to: "reversed", on: "reverse" },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
var _receiptFsm = null;
|
|
99
|
+
function _getReceiptFsm() {
|
|
100
|
+
if (_receiptFsm) return _receiptFsm;
|
|
101
|
+
try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
|
|
102
|
+
_receiptFsm = b.fsm.define({
|
|
103
|
+
name: "inventory_receipt",
|
|
104
|
+
initial: "pending",
|
|
105
|
+
states: { pending: {}, applied: {}, reversed: {} },
|
|
106
|
+
transitions: RECEIPT_TRANSITIONS.map(function (t) {
|
|
107
|
+
return { from: t.from, to: t.to, on: t.on };
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
return _receiptFsm;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _canReceipt(fromStatus, event) {
|
|
114
|
+
var fsm = _getReceiptFsm();
|
|
115
|
+
return fsm.restore({ state: fromStatus, history: [], context: {} }).can(event);
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
// ---- validators ---------------------------------------------------------
|
|
82
119
|
|
|
83
120
|
function _id(s, label) {
|
|
@@ -293,13 +330,32 @@ function create(opts) {
|
|
|
293
330
|
if (!receipt) {
|
|
294
331
|
throw new TypeError("inventory-receive.apply: receipt " + receiptId + " not found");
|
|
295
332
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (receipt.status
|
|
333
|
+
// Preserve the idempotent no-op on an already-'applied' receipt: the
|
|
334
|
+
// FSM has no apply edge out of 'applied', so short-circuit here BEFORE
|
|
335
|
+
// asserting the transition (regression-safe replay contract — callers
|
|
336
|
+
// replay apply() without surprise).
|
|
337
|
+
if (!_canReceipt(receipt.status, "apply")) {
|
|
338
|
+
if (receipt.status === "applied") {
|
|
339
|
+
return { id: receiptId, applied_count: 0, stock_changes: [] };
|
|
340
|
+
}
|
|
301
341
|
throw new TypeError("inventory-receive.apply: receipt is " + receipt.status + ", only pending receipts can be applied");
|
|
302
342
|
}
|
|
343
|
+
// Claim the pending -> applied transition atomically BEFORE restocking.
|
|
344
|
+
// Two concurrent applies both pass the read above, but only one UPDATE
|
|
345
|
+
// matches `status = 'pending'`; the loser sees rowCount 0 and returns the
|
|
346
|
+
// idempotent no-op instead of double-restocking the catalog. (Without
|
|
347
|
+
// this guard both calls would restock every line, inflating
|
|
348
|
+
// stock_on_hand.)
|
|
349
|
+
var ts = _now();
|
|
350
|
+
var claim = await query(
|
|
351
|
+
"UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'pending'",
|
|
352
|
+
[ts, receiptId],
|
|
353
|
+
);
|
|
354
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
355
|
+
// A concurrent call won the claim and is restocking (or already did).
|
|
356
|
+
// Replay-safe no-op so the loser doesn't double-apply.
|
|
357
|
+
return { id: receiptId, applied_count: 0, stock_changes: [] };
|
|
358
|
+
}
|
|
303
359
|
var stockChanges = [];
|
|
304
360
|
var applied = [];
|
|
305
361
|
try {
|
|
@@ -310,8 +366,8 @@ function create(opts) {
|
|
|
310
366
|
stockChanges.push({ sku: l.sku, qty: l.qty_received });
|
|
311
367
|
}
|
|
312
368
|
} catch (e) {
|
|
313
|
-
// Undo every successful restock so the database state matches
|
|
314
|
-
//
|
|
369
|
+
// Undo every successful restock so the database state matches the
|
|
370
|
+
// pre-apply snapshot, then release the claim back to 'pending' so the
|
|
315
371
|
// operator can fix the offending line and retry.
|
|
316
372
|
for (var j = applied.length - 1; j >= 0; j -= 1) {
|
|
317
373
|
try {
|
|
@@ -321,15 +377,16 @@ function create(opts) {
|
|
|
321
377
|
);
|
|
322
378
|
} catch (_e3) { /* drop-silent — the original apply error is what the operator needs to fix */ }
|
|
323
379
|
}
|
|
380
|
+
try {
|
|
381
|
+
await query(
|
|
382
|
+
"UPDATE inventory_receipts SET status = 'pending', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
|
|
383
|
+
[_now(), receiptId],
|
|
384
|
+
);
|
|
385
|
+
} catch (_e4) { /* drop-silent — the original apply error is the operator's signal */ }
|
|
324
386
|
var err = new Error("inventory-receive.apply: restock failed — " + (e && e.message || e));
|
|
325
387
|
err.cause = e;
|
|
326
388
|
throw err;
|
|
327
389
|
}
|
|
328
|
-
var ts = _now();
|
|
329
|
-
await query(
|
|
330
|
-
"UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2",
|
|
331
|
-
[ts, receiptId],
|
|
332
|
-
);
|
|
333
390
|
return {
|
|
334
391
|
id: receiptId,
|
|
335
392
|
applied_count: applied.length,
|
|
@@ -352,18 +409,55 @@ function create(opts) {
|
|
|
352
409
|
if (!receipt) {
|
|
353
410
|
throw new TypeError("inventory-receive.reverse: receipt " + receiptId + " not found");
|
|
354
411
|
}
|
|
355
|
-
if (receipt.status
|
|
412
|
+
if (!_canReceipt(receipt.status, "reverse")) {
|
|
356
413
|
throw new TypeError("inventory-receive.reverse: receipt is " + receipt.status + ", only applied receipts can be reversed");
|
|
357
414
|
}
|
|
415
|
+
// Claim the applied -> reversed transition atomically BEFORE decrementing.
|
|
416
|
+
// Two concurrent reverses both pass the read above, but only one UPDATE
|
|
417
|
+
// matches `status = 'applied'`; the loser refuses, so the shelf is
|
|
418
|
+
// decremented exactly once. (Without this guard both calls would
|
|
419
|
+
// decrement every line, destroying unrelated base stock.)
|
|
420
|
+
var claimTs = _now();
|
|
421
|
+
var claim = await query(
|
|
422
|
+
"UPDATE inventory_receipts SET status = 'reversed', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
|
|
423
|
+
[claimTs, receiptId],
|
|
424
|
+
);
|
|
425
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
426
|
+
throw new TypeError("inventory-receive.reverse: receipt " + receiptId +
|
|
427
|
+
" is no longer applied (reversed by a concurrent call)");
|
|
428
|
+
}
|
|
358
429
|
var stockChanges = [];
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
var
|
|
430
|
+
var decremented = [];
|
|
431
|
+
try {
|
|
432
|
+
for (var i = 0; i < receipt.lines.length; i += 1) {
|
|
433
|
+
var l = receipt.lines[i];
|
|
434
|
+
var ts = _now();
|
|
435
|
+
await query(
|
|
436
|
+
"UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
|
|
437
|
+
[l.qty_received, ts, l.sku],
|
|
438
|
+
);
|
|
439
|
+
decremented.push(l);
|
|
440
|
+
stockChanges.push({ sku: l.sku, qty: -l.qty_received });
|
|
441
|
+
}
|
|
442
|
+
} catch (e) {
|
|
443
|
+
// Compensate the decrements that already landed and release the claim so
|
|
444
|
+
// the receipt stays eligible for another reverse — otherwise a mid-loop
|
|
445
|
+
// failure strands it 'reversed' with only a partial stock rollback that
|
|
446
|
+
// the status check then refuses to retry.
|
|
447
|
+
for (var c = 0; c < decremented.length; c += 1) {
|
|
448
|
+
var dl = decremented[c];
|
|
449
|
+
try {
|
|
450
|
+
await query(
|
|
451
|
+
"UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
|
|
452
|
+
[dl.qty_received, _now(), dl.sku],
|
|
453
|
+
);
|
|
454
|
+
} catch (_compErr) { /* best-effort compensation; the claim release below restores retryability */ }
|
|
455
|
+
}
|
|
362
456
|
await query(
|
|
363
|
-
"UPDATE
|
|
364
|
-
[
|
|
457
|
+
"UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'reversed'",
|
|
458
|
+
[_now(), receiptId],
|
|
365
459
|
);
|
|
366
|
-
|
|
460
|
+
throw e;
|
|
367
461
|
}
|
|
368
462
|
// Append the reason into the receipt notes so the audit trail
|
|
369
463
|
// carries the rationale. Operators that want a richer reversal
|
|
@@ -377,9 +471,11 @@ function create(opts) {
|
|
|
377
471
|
newNotes = newNotes.slice(0, MAX_NOTES_LEN);
|
|
378
472
|
}
|
|
379
473
|
}
|
|
474
|
+
// Status was already flipped to 'reversed' by the atomic claim above;
|
|
475
|
+
// this write only stamps the reversal reason into the notes.
|
|
380
476
|
var ts2 = _now();
|
|
381
477
|
await query(
|
|
382
|
-
"UPDATE inventory_receipts SET
|
|
478
|
+
"UPDATE inventory_receipts SET notes = ?1, updated_at = ?2 WHERE id = ?3",
|
|
383
479
|
[newNotes, ts2, receiptId],
|
|
384
480
|
);
|
|
385
481
|
return {
|