@blamejs/blamejs-shop 0.4.22 → 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 +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1273 -15
- package/lib/asset-manifest.json +5 -5
- package/lib/checkout.js +70 -0
- 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-accounts.js +52 -1
- package/lib/operator-audit-log.js +186 -6
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +178 -69
- 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 +1088 -129
- 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/checkout.js
CHANGED
|
@@ -246,6 +246,60 @@ function create(deps) {
|
|
|
246
246
|
var backorder = deps.backorder || null;
|
|
247
247
|
var preorder = deps.preorder || null;
|
|
248
248
|
|
|
249
|
+
// Optional inbound-webhook replay defense. A validly-signed Stripe event
|
|
250
|
+
// can be replayed verbatim inside the ±5-minute signature tolerance: the
|
|
251
|
+
// signature still verifies, so signature-checking alone does not stop a
|
|
252
|
+
// replay, and the downstream order-state idempotency is keyed on order
|
|
253
|
+
// state (not event identity) so a refund/cancel replay or a replay that
|
|
254
|
+
// races the first delivery slips past it. When `webhookReplayQuery` (a
|
|
255
|
+
// D1 query fn) is wired, every verified Stripe event id is atomically
|
|
256
|
+
// recorded with an INSERT ... ON CONFLICT DO NOTHING; a replay loses the
|
|
257
|
+
// PRIMARY-KEY race and is treated as an already-processed no-op. The
|
|
258
|
+
// store composes b.nonceStore over a D1-backed atomic backend rather
|
|
259
|
+
// than a hand-rolled has/set (which would race). Absent — the handler is
|
|
260
|
+
// byte-identical to the un-wired flow (order-state idempotency still
|
|
261
|
+
// covers the common re-delivery), so this is additive, never required.
|
|
262
|
+
var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
|
|
263
|
+
? deps.webhookReplayQuery : null;
|
|
264
|
+
var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
|
|
265
|
+
var _stripeReplayStore = null;
|
|
266
|
+
function _stripeReplay() {
|
|
267
|
+
if (!webhookReplayQuery) return null;
|
|
268
|
+
if (!_stripeReplayStore) {
|
|
269
|
+
// Custom b.nonceStore backend: the atomicity lives in the D1
|
|
270
|
+
// INSERT ... ON CONFLICT DO NOTHING (the PRIMARY KEY race decides
|
|
271
|
+
// first-seen vs replay), so the check + insert can never interleave.
|
|
272
|
+
_stripeReplayStore = b.nonceStore.create({
|
|
273
|
+
backend: {
|
|
274
|
+
checkAndInsert: async function (eventId, expireAt) {
|
|
275
|
+
var nowMs = Date.now();
|
|
276
|
+
var r = await webhookReplayQuery(
|
|
277
|
+
"INSERT INTO stripe_webhook_events (event_id, first_seen_at, expires_at) " +
|
|
278
|
+
"VALUES (?1, ?2, ?3) ON CONFLICT (event_id) DO NOTHING",
|
|
279
|
+
[eventId, nowMs, expireAt],
|
|
280
|
+
);
|
|
281
|
+
// rowCount/changes === 1 → first sighting (recorded); 0 → replay.
|
|
282
|
+
var changes = (r && r.meta && typeof r.meta.changes === "number") ? r.meta.changes
|
|
283
|
+
: (r && typeof r.rowCount === "number") ? r.rowCount
|
|
284
|
+
: (r && typeof r.changes === "number") ? r.changes : 0;
|
|
285
|
+
return changes > 0;
|
|
286
|
+
},
|
|
287
|
+
purgeExpired: async function () {
|
|
288
|
+
var r = await webhookReplayQuery(
|
|
289
|
+
"DELETE FROM stripe_webhook_events WHERE expires_at < ?1",
|
|
290
|
+
[Date.now()],
|
|
291
|
+
);
|
|
292
|
+
if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
|
|
293
|
+
if (r && typeof r.rowCount === "number") return r.rowCount;
|
|
294
|
+
if (r && typeof r.changes === "number") return r.changes;
|
|
295
|
+
return 0;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return _stripeReplayStore;
|
|
301
|
+
}
|
|
302
|
+
|
|
249
303
|
// Reprice a list of cart lines through the quantity-discount engine.
|
|
250
304
|
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
251
305
|
// discounted unit for each line's SKU at its quantity. A line whose
|
|
@@ -1261,6 +1315,22 @@ function create(deps) {
|
|
|
1261
1315
|
var event = v.event;
|
|
1262
1316
|
var eventType = event && event.type;
|
|
1263
1317
|
|
|
1318
|
+
// Replay defense — atomically claim this event id the moment the
|
|
1319
|
+
// signature verifies, BEFORE any subscription routing or state
|
|
1320
|
+
// transition. A replayed (already-seen) event id loses the
|
|
1321
|
+
// PRIMARY-KEY race and short-circuits to a processed no-op so no
|
|
1322
|
+
// transition, refund-mirror, or subscription update runs twice. A
|
|
1323
|
+
// store error fails CLOSED inside the nonceStore (returns
|
|
1324
|
+
// not-fresh) — a replay is indistinguishable from a wiped store, so
|
|
1325
|
+
// refusing is the safe default. No-op when the store isn't wired.
|
|
1326
|
+
var replay = _stripeReplay();
|
|
1327
|
+
if (replay && event && typeof event.id === "string" && event.id.length > 0) {
|
|
1328
|
+
var fresh = await replay.checkAndInsert(event.id, Date.now() + STRIPE_REPLAY_TTL_MS);
|
|
1329
|
+
if (!fresh) {
|
|
1330
|
+
return { handled: true, event_type: eventType || null, skipped: "replay", event_id: event.id };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1264
1334
|
// Subscription events route to the subscriptions primitive
|
|
1265
1335
|
// (if wired). The one-time-order PaymentIntent path below
|
|
1266
1336
|
// stays unchanged.
|
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
|
|