@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.
@@ -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
- // Returns both the snapshot and the occurred_at so the write path
154
- // can guarantee strict monotonicity (see `_resolveOccurredAt`).
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
- // Two writes against the same customer in the same millisecond
171
- // would tie on `occurred_at` and make the "latest row" ambiguous.
172
- // Bump the requested timestamp to `prior + 1` when it would
173
- // collide (or land older than the prior row, which an
174
- // out-of-order operator write could trigger). The result is a
175
- // strictly-monotonic per-customer `occurred_at` sequence.
176
- function _resolveOccurredAt(requestedTs, latestTs) {
177
- if (latestTs == null) return requestedTs;
178
- if (requestedTs > latestTs) return requestedTs;
179
- return latestTs + 1;
180
- }
181
-
182
- async function _writeRow(customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts) {
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
- await query(
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
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
188
- [id, customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts],
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 id;
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 latest = await _readLatest(customerId);
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: after,
223
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
238
- if (amount > latest.balance) {
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: after,
254
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
276
- // Expire caps at the current balance operators running a
277
- // scheduled sweep over computed "expiring before X" amounts
278
- // should degrade gracefully rather than refusing when an
279
- // interim debit has already drained the wallet.
280
- var toBurn = amount > latest.balance ? latest.balance : amount;
281
- if (toBurn === 0) {
282
- // No-op write: persisting a zero-amount row would violate
283
- // the CHECK(amount_minor > 0) constraint. Surface a
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: latest.balance,
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: toBurn,
334
+ amount_minor: w.amount_minor,
308
335
  requested_minor: amount,
309
336
  reason: reason,
310
- balance_after_minor: after,
311
- occurred_at: ts,
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
- var latest = await _readLatest(customerId);
589
- // Cap the burn at the wallet's current balance.
590
- // Debits between the credit and the sweep may have spent
591
- // the expired amount already; we never drive the balance
592
- // negative. The expired credits were "first-out" from the
593
- // operator's POV but the schema doesn't track FIFO at
594
- // row-level, so cap by current balance and let the audit
595
- // trail reflect what was actually burned.
596
- var toBurn = pendingBurn > latest.balance ? latest.balance : pendingBurn;
597
- if (toBurn <= 0) {
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: toBurn,
609
- balance_after_minor: after,
610
- occurred_at: ts,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {