@blamejs/blamejs-shop 0.4.28 → 0.4.30
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 +1 -1
- package/SECURITY.md +25 -7
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +300 -129
- package/lib/compliance-export.js +32 -7
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +46 -0
- package/lib/payment.js +33 -2
- package/lib/stock-alerts.js +110 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +16 -5
- package/package.json +1 -1
package/lib/stock-alerts.js
CHANGED
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
* prior row's terminal state is cleared from the unique-index slot
|
|
34
34
|
* first.
|
|
35
35
|
*
|
|
36
|
+
* Privacy requests. `exportForCustomer({ customer_id?, email_hash? })`
|
|
37
|
+
* returns the subject's subscription rows (the plaintext address
|
|
38
|
+
* included — it is the subject's own data; token hashes excluded)
|
|
39
|
+
* and `eraseForCustomer({ customer_id?, email_hash?, dry_run? })`
|
|
40
|
+
* hard-deletes them — a stock alert is convenience data with no
|
|
41
|
+
* retention basis. Both match on customer_id OR the stock-alert-
|
|
42
|
+
* namespace email hash, so a caller holding either key covers the
|
|
43
|
+
* subject's rows.
|
|
44
|
+
*
|
|
36
45
|
* Composes:
|
|
37
46
|
* - `b.crypto.namespaceHash` — email hash + token hash.
|
|
38
47
|
* - `b.crypto.generateBytes` — 24 random bytes →
|
|
@@ -157,6 +166,43 @@ function _hashUnsubToken(plaintext) {
|
|
|
157
166
|
return b.crypto.namespaceHash(UNSUB_NAMESPACE, plaintext);
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
// Normalize the DSR selector for exportForCustomer / eraseForCustomer:
|
|
170
|
+
// at least one of customer_id / email_hash must be present. Bad UUID
|
|
171
|
+
// shape throws (operator catches the typo at the boundary); a null /
|
|
172
|
+
// absent key is simply not used in the predicate. `email_hash` is the
|
|
173
|
+
// stock-alert-namespace digest (hex from b.crypto.namespaceHash), so a
|
|
174
|
+
// generous shape gate (non-empty printable string) is all it needs.
|
|
175
|
+
function _dsrSelector(input, method) {
|
|
176
|
+
if (!input || typeof input !== "object") {
|
|
177
|
+
throw new TypeError("stockAlerts." + method + ": input object required");
|
|
178
|
+
}
|
|
179
|
+
var custId = _optUuid(input.customer_id, "customer_id");
|
|
180
|
+
var emailHash = null;
|
|
181
|
+
if (input.email_hash != null) {
|
|
182
|
+
if (typeof input.email_hash !== "string" || !input.email_hash.length ||
|
|
183
|
+
input.email_hash.length > 256 || /[\x00-\x1f\x7f]/.test(input.email_hash)) {
|
|
184
|
+
throw new TypeError("stockAlerts." + method + ": email_hash must be a non-empty printable string <= 256 chars when provided");
|
|
185
|
+
}
|
|
186
|
+
emailHash = input.email_hash;
|
|
187
|
+
}
|
|
188
|
+
if (custId == null && emailHash == null) {
|
|
189
|
+
throw new TypeError("stockAlerts." + method + ": at least one of customer_id / email_hash is required");
|
|
190
|
+
}
|
|
191
|
+
return { custId: custId, emailHash: emailHash };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build the `customer_id = ? OR email_hash = ?` predicate from whichever
|
|
195
|
+
// selector keys are present. Shared by export + erase so the two never
|
|
196
|
+
// diverge on which rows belong to the subject.
|
|
197
|
+
function _dsrWhere(sel) {
|
|
198
|
+
var ors = [];
|
|
199
|
+
var params = [];
|
|
200
|
+
var idx = 1;
|
|
201
|
+
if (sel.custId != null) { ors.push("customer_id = ?" + idx); params.push(sel.custId); idx += 1; }
|
|
202
|
+
if (sel.emailHash != null) { ors.push("email_hash = ?" + idx); params.push(sel.emailHash); idx += 1; }
|
|
203
|
+
return { clause: "(" + ors.join(" OR ") + ")", params: params };
|
|
204
|
+
}
|
|
205
|
+
|
|
160
206
|
// ---- factory ------------------------------------------------------------
|
|
161
207
|
|
|
162
208
|
function create(opts) {
|
|
@@ -235,6 +281,14 @@ function create(opts) {
|
|
|
235
281
|
if (existing) {
|
|
236
282
|
var stillLive = existing.notified_at == null && Number(existing.expires_at) > now;
|
|
237
283
|
if (stillLive) {
|
|
284
|
+
// A re-subscribe NEVER re-keys the existing row's customer_id.
|
|
285
|
+
// The email on this request is caller-supplied and unproven, so
|
|
286
|
+
// adopting a row first created by someone else would let a
|
|
287
|
+
// signed-in caller pull a stranger's subscription into their own
|
|
288
|
+
// account scope. A row stays owned by whoever the INSERT linked
|
|
289
|
+
// it to (or anonymous); anonymous rows are reachable for privacy
|
|
290
|
+
// requests via the email_hash key + bearer token, exactly as
|
|
291
|
+
// eraseForCustomer's residual-scope note documents.
|
|
238
292
|
return {
|
|
239
293
|
id: existing.id,
|
|
240
294
|
status: existing.confirmed_at == null ? "already-pending" : "already-confirmed",
|
|
@@ -427,6 +481,62 @@ function create(opts) {
|
|
|
427
481
|
return rows;
|
|
428
482
|
},
|
|
429
483
|
|
|
484
|
+
// exportForCustomer({ customer_id?, email_hash? }) — subject-access
|
|
485
|
+
// (Art. 15) read. A subscription is keyed to a person by EITHER
|
|
486
|
+
// customer_id (a signed-in subscriber) OR the module's own
|
|
487
|
+
// stock-alert-namespace email hash (an anonymous subscriber). Both
|
|
488
|
+
// keys are matched with OR so a caller holding both covers every row;
|
|
489
|
+
// a caller resolving the subject by customer_id alone (the DSR
|
|
490
|
+
// composition root — no raw email is held anywhere to re-hash under
|
|
491
|
+
// this namespace) covers every account-linked row. The exported shape
|
|
492
|
+
// includes `email_normalised` — the subject's own address, stored
|
|
493
|
+
// plaintext by design for the notification dispatcher — and excludes
|
|
494
|
+
// the confirmation / unsubscribe token hashes (bearer credentials,
|
|
495
|
+
// not subject data).
|
|
496
|
+
exportForCustomer: async function (input) {
|
|
497
|
+
var sel = _dsrSelector(input, "exportForCustomer");
|
|
498
|
+
var w = _dsrWhere(sel);
|
|
499
|
+
return (await query(
|
|
500
|
+
"SELECT id, email_hash, email_normalised, sku, variant_id, customer_id, " +
|
|
501
|
+
"confirmed_at, notified_at, expires_at, created_at " +
|
|
502
|
+
"FROM stock_alerts WHERE " + w.clause +
|
|
503
|
+
" ORDER BY created_at DESC, id DESC LIMIT ?" + (w.params.length + 1),
|
|
504
|
+
w.params.concat([MAX_LIST_LIMIT]),
|
|
505
|
+
)).rows;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
// eraseForCustomer({ customer_id?, email_hash?, dry_run? }) — GDPR
|
|
509
|
+
// Art. 17 erasure. A stock alert is pure convenience data holding a
|
|
510
|
+
// PLAINTEXT email with no retention basis, so erasure hard-DELETEs
|
|
511
|
+
// the rows (which also frees the (email, sku, variant) unique-index
|
|
512
|
+
// slot cleanly — the person can re-subscribe later). Same dual-key
|
|
513
|
+
// selector as the export so the two never diverge on which rows
|
|
514
|
+
// belong to the subject. `dry_run: true` counts the rows a wet run
|
|
515
|
+
// WOULD delete without mutating. Returns the DSR reader contract
|
|
516
|
+
// shape `{ table, deleted }`.
|
|
517
|
+
//
|
|
518
|
+
// Residual scope, stated rather than implied: rows subscribed
|
|
519
|
+
// anonymously (customer_id NULL) are reachable only via the
|
|
520
|
+
// email_hash key — a customer_id-only call cannot match them because
|
|
521
|
+
// the system never holds the raw address to re-derive this
|
|
522
|
+
// namespace's hash. Those rows stay covered by the retention TTL
|
|
523
|
+
// (default 90 days, swept by cleanupExpired) and by the per-row
|
|
524
|
+
// bearer unsubscribe token the subscriber holds.
|
|
525
|
+
eraseForCustomer: async function (input) {
|
|
526
|
+
var dryRun = !!(input && input.dry_run);
|
|
527
|
+
var sel = _dsrSelector(input, "eraseForCustomer");
|
|
528
|
+
var w = _dsrWhere(sel);
|
|
529
|
+
if (dryRun) {
|
|
530
|
+
var c = (await query(
|
|
531
|
+
"SELECT COUNT(*) AS n FROM stock_alerts WHERE " + w.clause,
|
|
532
|
+
w.params,
|
|
533
|
+
)).rows[0];
|
|
534
|
+
return { table: "stock_alerts", deleted: c ? Number(c.n) : 0 };
|
|
535
|
+
}
|
|
536
|
+
var r = await query("DELETE FROM stock_alerts WHERE " + w.clause, w.params);
|
|
537
|
+
return { table: "stock_alerts", deleted: Number((r && r.rowCount) || 0) };
|
|
538
|
+
},
|
|
539
|
+
|
|
430
540
|
// Operator-driven sweeper. Walks every pending (confirmed +
|
|
431
541
|
// un-notified + un-expired) subscription whose SKU now has
|
|
432
542
|
// catalog.inventory.stock_on_hand - stock_held > 0 and:
|
package/lib/store-credit.js
CHANGED
|
@@ -146,16 +146,17 @@ function create(opts) {
|
|
|
146
146
|
query = function (sql, params) { return b.externalDb.query(sql, params); };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// O(1) current-balance read: the latest row by `occurred_at DESC
|
|
150
|
-
// holds `balance_after_minor` as the denormalized snapshot. No SUM
|
|
149
|
+
// O(1) current-balance read: the latest row by `occurred_at DESC, id
|
|
150
|
+
// DESC` holds `balance_after_minor` as the denormalized snapshot. No SUM
|
|
151
151
|
// aggregation at read time. Falls through to 0 when no rows exist
|
|
152
|
-
// (a customer that has never had a ledger row has zero credit).
|
|
153
|
-
//
|
|
154
|
-
// can
|
|
152
|
+
// (a customer that has never had a ledger row has zero credit). The id
|
|
153
|
+
// tie-break keeps any legacy same-millisecond rows deterministic; new
|
|
154
|
+
// writes can't tie — _writeRowAtomic computes a strictly-monotonic
|
|
155
|
+
// per-customer occurred_at inside the INSERT itself.
|
|
155
156
|
async function _readLatest(customerId) {
|
|
156
157
|
var r = await query(
|
|
157
158
|
"SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
|
|
158
|
-
"WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
159
|
+
"WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
159
160
|
[customerId],
|
|
160
161
|
);
|
|
161
162
|
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
@@ -167,27 +168,62 @@ function create(opts) {
|
|
|
167
168
|
return latest.balance;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
171
|
+
// Single-statement guarded write — the concurrency spine of the wallet.
|
|
172
|
+
// The live balance AND the strictly-monotonic per-customer occurred_at
|
|
173
|
+
// are computed by correlated subqueries INSIDE the INSERT, so two
|
|
174
|
+
// concurrent writes can never base off the same stale snapshot: the
|
|
175
|
+
// statements serialize at the database and the second sees the first's
|
|
176
|
+
// row. (A JS-side read-then-write here double-fulfilled concurrent
|
|
177
|
+
// debits and silently dropped one of two same-millisecond credits.)
|
|
178
|
+
// `kind` selects the guard + arithmetic:
|
|
179
|
+
// credit — balance_after = live + amount, no balance gate (the in-SQL
|
|
180
|
+
// occurred_at is what closes the same-millisecond tie);
|
|
181
|
+
// debit — balance_after = live - amount, gated on live >= amount
|
|
182
|
+
// (zero rows = insufficient, or lost the race — same refusal);
|
|
183
|
+
// expire — burns MIN(amount, live), gated on live > 0 (zero rows =
|
|
184
|
+
// wallet already empty; callers degrade gracefully — by
|
|
185
|
+
// design, never a throw).
|
|
186
|
+
// Returns the written row's resolved values, or null when the guard
|
|
187
|
+
// refused the write.
|
|
188
|
+
async function _writeRowAtomic(kind, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs) {
|
|
183
189
|
var id = b.uuid.v7();
|
|
184
|
-
|
|
190
|
+
var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
|
|
191
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
192
|
+
var tsSub = "COALESCE((SELECT occurred_at FROM store_credit_ledger " +
|
|
193
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
194
|
+
var tsExpr = "CASE WHEN ?8 > " + tsSub + " THEN ?8 ELSE " + tsSub + " + 1 END";
|
|
195
|
+
var amountExpr, afterExpr, guard;
|
|
196
|
+
if (kind === "credit") {
|
|
197
|
+
amountExpr = "?3";
|
|
198
|
+
afterExpr = balSub + " + ?3";
|
|
199
|
+
guard = "1";
|
|
200
|
+
} else if (kind === "debit") {
|
|
201
|
+
amountExpr = "?3";
|
|
202
|
+
afterExpr = balSub + " - ?3";
|
|
203
|
+
guard = balSub + " >= ?3";
|
|
204
|
+
} else { // expire — burn MIN(amount, live balance)
|
|
205
|
+
amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
|
|
206
|
+
afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
|
|
207
|
+
guard = balSub + " > 0";
|
|
208
|
+
}
|
|
209
|
+
var res = await query(
|
|
185
210
|
"INSERT INTO store_credit_ledger " +
|
|
186
211
|
"(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
|
|
187
|
-
"
|
|
188
|
-
|
|
212
|
+
"SELECT ?1, ?2, ?9, " + amountExpr + ", ?4, ?5, ?6, " + afterExpr + ", ?7, " + tsExpr + " " +
|
|
213
|
+
"WHERE " + guard,
|
|
214
|
+
[id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, kind],
|
|
189
215
|
);
|
|
190
|
-
return
|
|
216
|
+
if (Number(res.rowCount || 0) === 0) return null;
|
|
217
|
+
var row = (await query(
|
|
218
|
+
"SELECT amount_minor, balance_after_minor, occurred_at FROM store_credit_ledger WHERE id = ?1",
|
|
219
|
+
[id],
|
|
220
|
+
)).rows[0];
|
|
221
|
+
return {
|
|
222
|
+
id: id,
|
|
223
|
+
amount_minor: row.amount_minor,
|
|
224
|
+
balance_after_minor: row.balance_after_minor,
|
|
225
|
+
occurred_at: row.occurred_at,
|
|
226
|
+
};
|
|
191
227
|
}
|
|
192
228
|
|
|
193
229
|
return {
|
|
@@ -206,21 +242,18 @@ function create(opts) {
|
|
|
206
242
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
207
243
|
if (requested == null) requested = _now();
|
|
208
244
|
|
|
209
|
-
var
|
|
210
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
211
|
-
var after = latest.balance + amount;
|
|
212
|
-
var id = await _writeRow(customerId, "credit", amount, source, sourceRef, null, after, expiresAt, ts);
|
|
245
|
+
var w = await _writeRowAtomic("credit", customerId, amount, source, sourceRef, null, expiresAt, requested);
|
|
213
246
|
|
|
214
247
|
return {
|
|
215
|
-
id: id,
|
|
248
|
+
id: w.id,
|
|
216
249
|
customer_id: customerId,
|
|
217
250
|
kind: "credit",
|
|
218
251
|
amount_minor: amount,
|
|
219
252
|
source: source,
|
|
220
253
|
source_ref: sourceRef,
|
|
221
254
|
expires_at: expiresAt,
|
|
222
|
-
balance_after_minor:
|
|
223
|
-
occurred_at:
|
|
255
|
+
balance_after_minor: w.balance_after_minor,
|
|
256
|
+
occurred_at: w.occurred_at,
|
|
224
257
|
};
|
|
225
258
|
},
|
|
226
259
|
|
|
@@ -234,24 +267,24 @@ function create(opts) {
|
|
|
234
267
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
235
268
|
if (requested == null) requested = _now();
|
|
236
269
|
|
|
237
|
-
|
|
238
|
-
|
|
270
|
+
// The balance gate lives INSIDE the insert — a refused write covers
|
|
271
|
+
// both "always insufficient" and "a concurrent debit drained it
|
|
272
|
+
// first", with no window between check and write.
|
|
273
|
+
var w = await _writeRowAtomic("debit", customerId, amount, null, null, orderId, null, requested);
|
|
274
|
+
if (!w) {
|
|
239
275
|
var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
|
|
240
276
|
insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
|
|
241
277
|
throw insufficient;
|
|
242
278
|
}
|
|
243
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
244
|
-
var after = latest.balance - amount;
|
|
245
|
-
var id = await _writeRow(customerId, "debit", amount, null, null, orderId, after, null, ts);
|
|
246
279
|
|
|
247
280
|
return {
|
|
248
|
-
id: id,
|
|
281
|
+
id: w.id,
|
|
249
282
|
customer_id: customerId,
|
|
250
283
|
kind: "debit",
|
|
251
284
|
amount_minor: amount,
|
|
252
285
|
order_id: orderId,
|
|
253
|
-
balance_after_minor:
|
|
254
|
-
occurred_at:
|
|
286
|
+
balance_after_minor: w.balance_after_minor,
|
|
287
|
+
occurred_at: w.occurred_at,
|
|
255
288
|
};
|
|
256
289
|
},
|
|
257
290
|
|
|
@@ -272,18 +305,15 @@ function create(opts) {
|
|
|
272
305
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
273
306
|
if (requested == null) requested = _now();
|
|
274
307
|
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// structured refusal so the caller can distinguish "already
|
|
285
|
-
// empty" from "actually burned N". A no-op expire is a
|
|
286
|
-
// valid outcome of a bulk sweep — don't throw.
|
|
308
|
+
// Expire caps at the current balance INSIDE the insert — operators
|
|
309
|
+
// running a scheduled sweep over computed "expiring before X"
|
|
310
|
+
// amounts degrade gracefully rather than refusing when an interim
|
|
311
|
+
// debit has already drained the wallet. A refused write (wallet
|
|
312
|
+
// already at zero) is the structured no-op below, never a throw —
|
|
313
|
+
// by design: a no-op expire is a valid outcome of a bulk sweep,
|
|
314
|
+
// and a zero-amount row would violate CHECK(amount_minor > 0).
|
|
315
|
+
var w = await _writeRowAtomic("expire", customerId, amount, null, reason, null, null, requested);
|
|
316
|
+
if (!w) {
|
|
287
317
|
return {
|
|
288
318
|
id: null,
|
|
289
319
|
customer_id: customerId,
|
|
@@ -291,24 +321,21 @@ function create(opts) {
|
|
|
291
321
|
amount_minor: 0,
|
|
292
322
|
requested_minor: amount,
|
|
293
323
|
reason: reason,
|
|
294
|
-
balance_after_minor:
|
|
324
|
+
balance_after_minor: await _currentBalance(customerId),
|
|
295
325
|
occurred_at: requested,
|
|
296
326
|
noop: true,
|
|
297
327
|
};
|
|
298
328
|
}
|
|
299
|
-
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
300
|
-
var after = latest.balance - toBurn;
|
|
301
|
-
var id = await _writeRow(customerId, "expire", toBurn, null, reason, null, after, null, ts);
|
|
302
329
|
|
|
303
330
|
return {
|
|
304
|
-
id: id,
|
|
331
|
+
id: w.id,
|
|
305
332
|
customer_id: customerId,
|
|
306
333
|
kind: "expire",
|
|
307
|
-
amount_minor:
|
|
334
|
+
amount_minor: w.amount_minor,
|
|
308
335
|
requested_minor: amount,
|
|
309
336
|
reason: reason,
|
|
310
|
-
balance_after_minor:
|
|
311
|
-
occurred_at:
|
|
337
|
+
balance_after_minor: w.balance_after_minor,
|
|
338
|
+
occurred_at: w.occurred_at,
|
|
312
339
|
noop: false,
|
|
313
340
|
};
|
|
314
341
|
},
|
|
@@ -585,29 +612,22 @@ function create(opts) {
|
|
|
585
612
|
continue;
|
|
586
613
|
}
|
|
587
614
|
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// the
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
// trail
|
|
596
|
-
var
|
|
597
|
-
if (
|
|
598
|
-
// Wallet already empty — record nothing (no CHECK > 0
|
|
599
|
-
// violation). Operator can reconcile via history.
|
|
600
|
-
continue;
|
|
601
|
-
}
|
|
602
|
-
var ts = _resolveOccurredAt(now, latest.occurred_at);
|
|
603
|
-
var after = latest.balance - toBurn;
|
|
604
|
-
var id = await _writeRow(customerId, "expire", toBurn, null, SWEEP_SOURCE_REF, null, after, null, ts);
|
|
615
|
+
// The burn caps at the wallet's current balance INSIDE the
|
|
616
|
+
// guarded insert. Debits between the credit and the sweep may
|
|
617
|
+
// have spent the expired amount already; the write never drives
|
|
618
|
+
// the balance negative, and a wallet already at zero refuses the
|
|
619
|
+
// write entirely (no CHECK > 0 violation; operator reconciles
|
|
620
|
+
// via history). The expired credits were "first-out" from the
|
|
621
|
+
// operator's POV — the schema doesn't track FIFO at row level,
|
|
622
|
+
// so the audit trail reflects what was actually burned.
|
|
623
|
+
var w = await _writeRowAtomic("expire", customerId, pendingBurn, null, SWEEP_SOURCE_REF, null, null, now);
|
|
624
|
+
if (!w) continue;
|
|
605
625
|
processed.push({
|
|
606
|
-
id: id,
|
|
626
|
+
id: w.id,
|
|
607
627
|
customer_id: customerId,
|
|
608
|
-
amount_minor:
|
|
609
|
-
balance_after_minor:
|
|
610
|
-
occurred_at:
|
|
628
|
+
amount_minor: w.amount_minor,
|
|
629
|
+
balance_after_minor: w.balance_after_minor,
|
|
630
|
+
occurred_at: w.occurred_at,
|
|
611
631
|
});
|
|
612
632
|
}
|
|
613
633
|
return { processed: processed, swept_at: now };
|
package/lib/storefront.js
CHANGED
|
@@ -14864,7 +14864,7 @@ function mount(router, deps) {
|
|
|
14864
14864
|
// the buyer must lower a quantity or drop a line; nothing was
|
|
14865
14865
|
// charged and any holds placed mid-confirm were already released.
|
|
14866
14866
|
if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0 ||
|
|
14867
|
-
code === "INSUFFICIENT_STOCK") {
|
|
14867
|
+
code === "INSUFFICIENT_STOCK" || code === "AUTO_DISCOUNT_EXHAUSTED") {
|
|
14868
14868
|
try {
|
|
14869
14869
|
var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
14870
14870
|
if (coLines.length) {
|
|
@@ -14998,7 +14998,7 @@ function mount(router, deps) {
|
|
|
14998
14998
|
// Customer-correctable credit errors (bad gift-card code,
|
|
14999
14999
|
// insufficient loyalty balance, points on a guest cart) are 400s
|
|
15000
15000
|
// whose message the button surfaces inline.
|
|
15001
|
-
var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0;
|
|
15001
|
+
var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0 || ecode === "AUTO_DISCOUNT_EXHAUSTED";
|
|
15002
15002
|
// Out-of-stock is a 409 (conflict) carrying the friendly per-line
|
|
15003
15003
|
// message so the PayPal button surfaces it; nothing was charged
|
|
15004
15004
|
// and the mid-confirm holds were already released.
|
|
@@ -21025,11 +21025,22 @@ function mount(router, deps) {
|
|
|
21025
21025
|
var body = req.body || {};
|
|
21026
21026
|
var cartCount = 0;
|
|
21027
21027
|
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
21028
|
+
// Link the subscription to the signed-in customer when there is one,
|
|
21029
|
+
// so the row joins the account's privacy export / erasure scope. A
|
|
21030
|
+
// missing, stale, or revoked auth cookie reads as signed-out and the
|
|
21031
|
+
// anonymous subscribe (the primary, edge-served PDP flow — this route
|
|
21032
|
+
// stays in EDGE_POST_PATHS, no auth required) is unchanged.
|
|
21033
|
+
var saCustomerId = null;
|
|
21034
|
+
try {
|
|
21035
|
+
var saEnv = _currentCustomerEnv(req);
|
|
21036
|
+
if (saEnv && !(await _sessionRevoked(saEnv))) saCustomerId = saEnv.customer_id;
|
|
21037
|
+
} catch (_e) { saCustomerId = null; }
|
|
21028
21038
|
try {
|
|
21029
21039
|
var result = await deps.stockAlerts.subscribe({
|
|
21030
|
-
email:
|
|
21031
|
-
sku:
|
|
21032
|
-
variant_id:
|
|
21040
|
+
email: body.email,
|
|
21041
|
+
sku: body.sku,
|
|
21042
|
+
variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
|
|
21043
|
+
customer_id: saCustomerId,
|
|
21033
21044
|
});
|
|
21034
21045
|
// Only a brand-new subscription carries a plaintext token. Send the
|
|
21035
21046
|
// confirmation email best-effort; a mailer hiccup must not 500 the
|
package/package.json
CHANGED