@blamejs/blamejs-shop 0.4.28 → 0.4.29
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 +1 -1
- package/SECURITY.md +9 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +300 -129
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/payment.js +33 -2
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +2 -2
- package/package.json +1 -1
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.
|
package/package.json
CHANGED