@blamejs/blamejs-shop 0.4.18 → 0.4.20

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/lib/giftcards.js CHANGED
@@ -346,6 +346,66 @@ function create(opts) {
346
346
  };
347
347
  },
348
348
 
349
+ // Credit a card's spend back when the order that redeemed it never
350
+ // completed (abandoned / cancelled / refunded). `redeem` debited the
351
+ // card row's balance at checkout; if the order dies, that money must
352
+ // return to the card or the customer's balance is silently gone.
353
+ //
354
+ // Keyed on the ORDER id — the order FSM drives this on its cancel /
355
+ // refund edges (the same transitions that release inventory holds), so
356
+ // reversal is transition-driven, not a separate operator action. Every
357
+ // unreversed redemption against the order is restored.
358
+ //
359
+ // Idempotent + concurrency-safe: each redemption is claimed with
360
+ // `WHERE id = ? AND reversed_at IS NULL` and the rowCount checked, so a
361
+ // double-fire (the stale-order reaper racing a payment-failed webhook,
362
+ // or a re-delivered cancel) credits the balance back exactly once. The
363
+ // balance restore is itself bounded by the card's issued_minor CHECK, so
364
+ // it can never push the card above its original face value. A card that
365
+ // had drained to `redeemed` is reactivated since it now carries balance
366
+ // again. Returns the list of reversed redemptions (empty when there was
367
+ // nothing to reverse — an order that used no gift card, or one already
368
+ // fully reversed).
369
+ reverseRedemption: async function (orderId) {
370
+ _uuid(orderId, "order_id");
371
+ var rows = (await query(
372
+ "SELECT id, giftcard_id, amount_minor FROM giftcard_redemptions " +
373
+ "WHERE order_id = ?1 AND reversed_at IS NULL",
374
+ [orderId],
375
+ )).rows;
376
+ var reversed = [];
377
+ for (var i = 0; i < rows.length; i += 1) {
378
+ var red = rows[i];
379
+ var ts = _now();
380
+ // Claim the redemption first — the unreversed predicate is the
381
+ // serialization point so two concurrent reversals can't both credit.
382
+ var claim = await query(
383
+ "UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
384
+ [ts, red.id],
385
+ );
386
+ if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
387
+ // Restore the spendable balance on the card row. Capped at the card's
388
+ // issued_minor by the schema CHECK; the amount restored is exactly
389
+ // what this redemption debited, so it can't exceed the face value.
390
+ // Reactivate a card that had drained to `redeemed` — it carries
391
+ // balance again. A `voided`/`expired` card stays in its terminal
392
+ // status (the balance is restored on the row for reconciliation, but
393
+ // a voided/expired card isn't spendable regardless).
394
+ await query(
395
+ "UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
396
+ "status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
397
+ "updated_at = ?2 WHERE id = ?3",
398
+ [red.amount_minor, ts, red.giftcard_id],
399
+ );
400
+ reversed.push({
401
+ redemption_id: red.id,
402
+ gift_card_id: red.giftcard_id,
403
+ amount_minor: red.amount_minor,
404
+ });
405
+ }
406
+ return reversed;
407
+ },
408
+
349
409
  "void": async function (id, opts2) {
350
410
  opts2 = opts2 || {};
351
411
  _uuid(id, "giftcard id");
package/lib/order.js CHANGED
@@ -58,6 +58,25 @@ var ORDER_TRANSITIONS = Object.freeze([
58
58
  { from: "delivered", to: "refunded", on: "refund", label: "Refund" },
59
59
  ]);
60
60
 
61
+ // Pickup (BOPIS) edges. An in-store collection has no carrier ship leg:
62
+ // the goods sit on the hold shelf and the customer walks in. So a pickup
63
+ // order goes straight from `paid` (or `fulfilling`, if the picker started
64
+ // staging) to `delivered` on a single `mark_picked_up` event — driving it
65
+ // through mark_shipped would record a carrier handoff that never happened.
66
+ // These edges are kept OUT of ORDER_TRANSITIONS (and therefore out of
67
+ // transitionsFrom) so the operator order-detail page doesn't sprout a
68
+ // "mark picked up" button on every paid order — the click-and-collect
69
+ // primitive fires the event when the front-counter operator captures the
70
+ // pickup. They ARE merged into the FSM definition below so the transition
71
+ // is legal: previously markPickedUp tried `mark_delivered` from `paid`,
72
+ // which the FSM refused (delivered is only reachable from shipped) and the
73
+ // caller swallowed — leaving the pickup schedule `picked_up` while the
74
+ // parent order stayed stuck at paid/fulfilling.
75
+ var PICKUP_TRANSITIONS = Object.freeze([
76
+ { from: "paid", to: "delivered", on: "mark_picked_up" },
77
+ { from: "fulfilling", to: "delivered", on: "mark_picked_up" },
78
+ ]);
79
+
61
80
  function _getOrderFsm() {
62
81
  if (_orderFsm) return _orderFsm;
63
82
  // b.fsm emits audit events under the 'fsm' namespace —
@@ -76,7 +95,7 @@ function _getOrderFsm() {
76
95
  refunded: {},
77
96
  cancelled: {},
78
97
  },
79
- transitions: ORDER_TRANSITIONS.map(function (t) {
98
+ transitions: ORDER_TRANSITIONS.concat(PICKUP_TRANSITIONS).map(function (t) {
80
99
  return { from: t.from, to: t.to, on: t.on };
81
100
  }),
82
101
  });
@@ -217,6 +236,28 @@ function create(opts) {
217
236
  // settlement failures still surface to the audit sink (b.audit.safeEmit
218
237
  // below), just not the durable error feed.
219
238
  var errorLog = opts.errorLog || null;
239
+ // Optional gift-card handle — when present, the order FSM credits a
240
+ // gift-card spend back when the order dies without completing. A checkout
241
+ // that partially covers payment with a gift card debits the card's balance
242
+ // at confirm; if the order is then cancelled (the stale-pending reaper, an
243
+ // explicit cancel) or refunded (a payment-failed / refund webhook), that
244
+ // debit must return to the card or the customer's balance is silently
245
+ // burned. This mirrors the inventory-hold release: it's transition-driven
246
+ // on the same cancel / refund edges, runs synchronously before the
247
+ // fire-and-forget fan-outs, and is idempotent (reverseRedemption claims
248
+ // each redemption with an unreversed predicate) so a re-delivered webhook
249
+ // can't double-credit. Opt-in like the other handles; absent it, an
250
+ // unwired deploy (or a test with no gift cards) runs unchanged.
251
+ var giftCards = opts.giftCards || null;
252
+ if (giftCards && typeof giftCards.reverseRedemption !== "function") {
253
+ throw new TypeError("order.create: opts.giftCards must expose a reverseRedemption(order_id) method");
254
+ }
255
+ // Optional gift-card ledger handle — when present alongside giftCards, a
256
+ // reversal also writes a refund_to_giftcard credit so the append-only
257
+ // ledger history records the money returning to the card (the card row's
258
+ // balance is authoritative; the ledger is the audit trail surfaced in the
259
+ // admin console).
260
+ var giftCardLedger = opts.giftCardLedger || null;
220
261
  // Pagination cursors for listForCustomer are HMAC-tagged via
221
262
  // b.pagination so an operator can't hand-craft one to skip past a
222
263
  // hidden order or replay across deployments. The secret defaults
@@ -273,6 +314,60 @@ function create(opts) {
273
314
  }
274
315
  }
275
316
 
317
+ // Credit back any gift-card spend on an order that died without
318
+ // completing — the cancel / refund edge releasing the customer's money,
319
+ // mirroring how _settleSku releases an inventory hold. reverseRedemption
320
+ // is idempotent (each redemption claimed with an unreversed predicate), so
321
+ // a re-delivered webhook or the reaper racing the cancel can't double-
322
+ // credit. For each restored redemption a refund_to_giftcard ledger credit
323
+ // is written when the ledger is wired so the audit trail records the money
324
+ // returning.
325
+ //
326
+ // drop-silent-with-capture — same discipline as _settleSku: the cancel /
327
+ // refund has already persisted and the webhook driving it MUST return 2xx,
328
+ // so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
329
+ // `order.giftcard.reversal.error` audit event plus, when the error-log
330
+ // handle is wired, a durable /admin/errors row) for manual reconciliation.
331
+ async function _settleGiftCards(orderId) {
332
+ if (!giftCards) return;
333
+ try {
334
+ var reversed = await giftCards.reverseRedemption(orderId);
335
+ if (giftCardLedger && typeof giftCardLedger.credit === "function") {
336
+ for (var i = 0; i < reversed.length; i += 1) {
337
+ var rev = reversed[i];
338
+ try {
339
+ // allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
340
+ await giftCardLedger.credit({
341
+ gift_card_id: rev.gift_card_id,
342
+ amount_minor: rev.amount_minor,
343
+ source: "refund_to_giftcard",
344
+ source_ref: orderId,
345
+ });
346
+ } catch (_ledgerErr) { /* drop-silent — the card-row balance restore above is authoritative; the ledger credit is the audit trail */ }
347
+ }
348
+ }
349
+ } catch (e) {
350
+ var message = "order.giftcard-reversal failed — order=" + orderId + ": " + (e && e.message || e);
351
+ try {
352
+ b.audit.safeEmit({
353
+ action: "order.giftcard.reversal.error",
354
+ outcome: "failure",
355
+ metadata: { order_id: orderId, message: (e && e.message) || String(e) },
356
+ });
357
+ } catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
358
+ if (errorLog && typeof errorLog.captureServerError === "function") {
359
+ try {
360
+ await errorLog.captureServerError({
361
+ route: "/order/" + orderId + "/giftcard-reversal",
362
+ method: "POST",
363
+ status: 500,
364
+ message: message,
365
+ });
366
+ } catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
367
+ }
368
+ }
369
+ }
370
+
276
371
  return {
277
372
  TERMINAL_STATES: TERMINAL_STATES,
278
373
 
@@ -477,6 +572,21 @@ function create(opts) {
477
572
  }
478
573
  }
479
574
  }
575
+ // Gift-card settlement — SYNCHRONOUS, alongside the inventory release.
576
+ // An order that reaches a terminal state WITHOUT completing the sale
577
+ // (cancelled — pending-abandon or post-paid; refunded — payment failed
578
+ // or a refund issued) must return any gift-card spend to the card.
579
+ // Unlike inventory (where a refund deliberately doesn't auto-restock a
580
+ // physical good), the gift-card credit is the customer's own money, so
581
+ // it's released on every death edge. reverseRedemption is idempotent,
582
+ // so a re-delivered webhook or the reaper racing a cancel credits back
583
+ // exactly once. Drop-silent-with-capture (see _settleGiftCards): the
584
+ // transition has already persisted and the webhook must return 2xx, so
585
+ // a reversal failure surfaces loudly for reconciliation rather than
586
+ // 500ing the request.
587
+ if (result.to === "cancelled" || result.to === "refunded") {
588
+ await _settleGiftCards(orderId);
589
+ }
480
590
  // Fan-out to merchant webhook subscribers is fire-and-forget. The
481
591
  // transition has already persisted; the request must not wait on
482
592
  // outbound HTTP, or a slow / unreachable endpoint would block the
package/lib/returns.js CHANGED
@@ -376,21 +376,61 @@ function create(opts) {
376
376
  var refundedAt = _epochOrNull(input.refunded_at, "refunded_at");
377
377
  var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
378
378
 
379
- var current = await this._currentStatus(rmaId);
380
- _assertTransition(current, "refund");
381
-
382
379
  var ts = _now();
383
380
  var rfAt = refundedAt == null ? ts : refundedAt;
384
- await query(
381
+ // Atomic claim: the `AND status = 'received'` predicate is the
382
+ // serialization point. Two concurrent refund POSTs (an operator
383
+ // double-click, two operators on the same RMA) both read 'received'
384
+ // up the stack, but on D1 a single UPDATE is atomic — exactly one
385
+ // matches the row and writes 'refunded'; the loser matches zero rows
386
+ // and is refused. Without this the read-then-write let both pass and
387
+ // both moved the RMA to refunded (and, on the provider-backed path,
388
+ // both issued a refund). A zero-row result distinguishes "already
389
+ // refunded / not received" — surfaced as RMA_TRANSITION_REFUSED so
390
+ // the request layer maps it to 409, the same shape the prior
391
+ // _assertTransition refusal produced.
392
+ var upd = await query(
385
393
  "UPDATE return_authorizations SET status = 'refunded', " +
386
394
  "refunded_at = ?1, " +
387
395
  "operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
388
- "updated_at = ?3 WHERE id = ?4",
396
+ "updated_at = ?3 WHERE id = ?4 AND status = 'received'",
389
397
  [rfAt, operatorNotes, ts, rmaId],
390
398
  );
399
+ if (Number(upd.rowCount || 0) === 0) {
400
+ // Either the RMA doesn't exist or it isn't in 'received'. Read the
401
+ // current status to surface the right typed refusal (RMA_NOT_FOUND
402
+ // vs RMA_TRANSITION_REFUSED) so the caller's mapping is unchanged.
403
+ var current = await this._currentStatus(rmaId);
404
+ _assertTransition(current, "refund");
405
+ // _assertTransition throws for any non-'received' state; reaching
406
+ // here means the RMA was 'received' a moment ago but a concurrent
407
+ // claim won the row first. Refuse with the transition-refused shape.
408
+ var raced = new Error("returns: refund already claimed for rma " + rmaId);
409
+ raced.code = "RMA_TRANSITION_REFUSED";
410
+ throw raced;
411
+ }
391
412
  return await this.get(rmaId);
392
413
  },
393
414
 
415
+ // Revert a refund claim back to 'received'. The provider-backed refund
416
+ // flow claims the RMA (refund() above) BEFORE moving money so a
417
+ // concurrent racer is locked out; if the provider call then fails, the
418
+ // caller releases the claim so a retry can run. Atomic + self-targeting:
419
+ // the `AND status = 'refunded'` predicate makes a double-release (or a
420
+ // release of an RMA a webhook already advanced) a no-op, never an
421
+ // underflow. Returns true when the claim was released, false when there
422
+ // was nothing to release.
423
+ releaseRefundClaim: async function (rmaId) {
424
+ _uuid(rmaId, "rma id");
425
+ var ts = _now();
426
+ var upd = await query(
427
+ "UPDATE return_authorizations SET status = 'received', refunded_at = NULL, " +
428
+ "updated_at = ?1 WHERE id = ?2 AND status = 'refunded'",
429
+ [ts, rmaId],
430
+ );
431
+ return Number(upd.rowCount || 0) === 1;
432
+ },
433
+
394
434
  reject: async function (rmaId, input) {
395
435
  _uuid(rmaId, "rma id");
396
436
  if (!input || typeof input !== "object") {
package/lib/storefront.js CHANGED
@@ -13999,6 +13999,16 @@ function mount(router, deps) {
13999
13999
  // fat-fingered value re-prompts rather than 500-ing checkout.
14000
14000
  var code = (e && typeof e.code === "string") ? e.code : "";
14001
14001
  var msg = (e && e.message) || "checkout failed";
14002
+ // Lost the single-charge claim: another POST for this cart (a double-
14003
+ // click / second tab) is already converting it. The winner created
14004
+ // (or is creating) the one order; bounce this loser to the cart so it
14005
+ // can't start a second checkout. The winner's redirect carries the
14006
+ // shopper to /pay or /orders — this branch just refuses the duplicate.
14007
+ if (code === "CHECKOUT_IN_PROGRESS") {
14008
+ res.status(303);
14009
+ res.setHeader && res.setHeader("location", "/cart");
14010
+ return res.end ? res.end() : res.send("");
14011
+ }
14002
14012
  // A coded gift-card / loyalty / out-of-stock error is something the
14003
14013
  // shopper can fix in place — re-render the checkout form with the
14004
14014
  // message inline (preserving the cart + their prefilled fields where
@@ -17968,6 +17978,18 @@ function mount(router, deps) {
17968
17978
  var auth = _returnsAuth(req, res); if (!auth) return;
17969
17979
  var order = await _ownedOrder(req, res, auth); if (!order) return;
17970
17980
  var cartCount = await _cartCountForReq(req);
17981
+ // An ineligible order (unpaid, cancelled, or already refunded) never
17982
+ // gets the open-return form — the same window the return button is
17983
+ // gated on. Without this the form renders on, say, a refunded order
17984
+ // and the POST below would mint an RMA the operator could then refund
17985
+ // a SECOND time through the provider.
17986
+ if (!_orderEligibleForReturn(order.status)) {
17987
+ return _send(res, 400, renderReturnForm({
17988
+ order: order, lines: order.lines || [],
17989
+ notice: "This order isn't eligible for a return.",
17990
+ shop_name: shopName, cart_count: cartCount,
17991
+ }));
17992
+ }
17971
17993
  _send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
17972
17994
  });
17973
17995
 
@@ -17976,6 +17998,21 @@ function mount(router, deps) {
17976
17998
  var order = await _ownedOrder(req, res, auth); if (!order) return;
17977
17999
  var body = req.body || {};
17978
18000
  var cartCount = await _cartCountForReq(req);
18001
+ // Server-side eligibility gate — the load-bearing check. A return is
18002
+ // only openable while the goods are in the customer's hands and paid
18003
+ // for (paid / fulfilling / shipped / delivered); an unpaid pending
18004
+ // order, a cancelled one, or an already-refunded one is refused. The
18005
+ // form/button gate on the same predicate, but a forged direct POST —
18006
+ // or an order that changed state between the GET and this POST — must
18007
+ // not slip an RMA onto an ineligible order (which would enable a
18008
+ // second provider refund downstream).
18009
+ if (!_orderEligibleForReturn(order.status)) {
18010
+ return _send(res, 400, renderReturnForm({
18011
+ order: order, lines: order.lines || [],
18012
+ notice: "This order isn't eligible for a return.",
18013
+ shop_name: shopName, cart_count: cartCount,
18014
+ }));
18015
+ }
17979
18016
  // Build the return lines from the order's own lines (authoritative
17980
18017
  // sku/qty), keyed by the checkboxes the customer ticked — never
17981
18018
  // trust a client-supplied sku.
package/lib/webhooks.js CHANGED
@@ -214,27 +214,59 @@ function create(opts) {
214
214
  return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
215
215
  }
216
216
 
217
+ // The marker a refusal row carries in `last_error`. Rows bearing it are
218
+ // EXCLUDED from the rate-limit count — a refusal is the gate's own
219
+ // output, not a real delivery, so counting it would let the gate
220
+ // perpetuate its own throttle: once tripped, the refusal rows alone keep
221
+ // the window "full" and every subsequent send is refused forever even
222
+ // after the real deliveries age out.
223
+ var RATE_LIMITED_MARKER = "rate-limited";
224
+
217
225
  async function _checkRateLimit(endpointRow) {
218
226
  var limit = endpointRow.rate_limit_per_minute;
219
227
  if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
220
228
  var windowStart = nowFn() - RATE_WINDOW_MS;
221
229
  var r = await query(
222
- "SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id = ?1 AND created_at > ?2",
223
- [endpointRow.id, windowStart],
230
+ "SELECT count(*) AS n FROM webhook_deliveries " +
231
+ "WHERE endpoint_id = ?1 AND created_at > ?2 " +
232
+ "AND (last_error IS NULL OR last_error != ?3)",
233
+ [endpointRow.id, windowStart, RATE_LIMITED_MARKER],
224
234
  );
225
235
  var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
226
236
  return n < limit;
227
237
  }
228
238
 
239
+ // Surface a single refusal row per endpoint per window so the operator
240
+ // sees the throttle in the admin feed — but DON'T grow the table by one
241
+ // row per suppressed attempt. A flood of suppressed events against a
242
+ // wedged receiver would otherwise write unbounded refusal rows. When a
243
+ // recent refusal row already exists for this endpoint, refresh its
244
+ // last_attempted_at (collapse) instead of inserting another.
229
245
  async function _persistRateLimited(endpointRow, eventType, payloadJson) {
230
- var deliveryId = b.uuid.v7();
231
246
  var ts = nowFn();
247
+ var windowStart = ts - RATE_WINDOW_MS;
248
+ var existing = await query(
249
+ "SELECT id FROM webhook_deliveries " +
250
+ "WHERE endpoint_id = ?1 AND last_error = ?2 AND created_at > ?3 " +
251
+ "ORDER BY created_at DESC LIMIT 1",
252
+ [endpointRow.id, RATE_LIMITED_MARKER, windowStart],
253
+ );
254
+ if (existing.rows[0]) {
255
+ var existingId = existing.rows[0].id;
256
+ await query(
257
+ "UPDATE webhook_deliveries SET last_attempted_at = ?1, event_type = ?2, payload_json = ?3 " +
258
+ "WHERE id = ?4",
259
+ [ts, eventType, payloadJson, existingId],
260
+ );
261
+ return await _getDelivery(existingId);
262
+ }
263
+ var deliveryId = b.uuid.v7();
232
264
  await query(
233
265
  "INSERT INTO webhook_deliveries " +
234
266
  "(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
235
267
  " last_attempted_at, created_at) " +
236
268
  "VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
237
- [deliveryId, endpointRow.id, eventType, payloadJson, "rate-limited", ts, ts],
269
+ [deliveryId, endpointRow.id, eventType, payloadJson, RATE_LIMITED_MARKER, ts, ts],
238
270
  );
239
271
  return await _getDelivery(deliveryId);
240
272
  }
@@ -311,6 +343,13 @@ function create(opts) {
311
343
  // per-endpoint feed still surfaces the failure. The DLQ row
312
344
  // carries the full payload so replayFromDlq can re-queue
313
345
  // without consulting the original delivery.
346
+ //
347
+ // Idempotent per delivery: the INSERT...SELECT writes only when no
348
+ // dead-letter row already exists for this delivery_id. Without it, a
349
+ // manual retry of an already-exhausted delivery dropped a duplicate
350
+ // DLQ row on every click. The migration 0215 UNIQUE(delivery_id) is
351
+ // the schema backstop; this guard avoids relying on a swallowed
352
+ // constraint error.
314
353
  var dlqId = b.uuid.v7();
315
354
  var firstAttemptedAt;
316
355
  var r = await query(
@@ -322,7 +361,8 @@ function create(opts) {
322
361
  "INSERT INTO webhook_dlq " +
323
362
  "(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
324
363
  " last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
325
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
364
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 " +
365
+ "WHERE NOT EXISTS (SELECT 1 FROM webhook_dlq WHERE delivery_id = ?3)",
326
366
  [dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
327
367
  lastStatus, lastError, firstAttemptedAt, ts, ts],
328
368
  );
@@ -443,6 +483,16 @@ function create(opts) {
443
483
  _uuid(deliveryId, "delivery id");
444
484
  var d = await _getDelivery(deliveryId);
445
485
  if (!d) return null;
486
+ // An exhausted delivery (attempts at/over the max) has already
487
+ // moved to the DLQ — re-attempting it would push attempts past the
488
+ // max and drop a DUPLICATE DLQ row on every click. Answer a no-op
489
+ // (return the row unchanged) so the operator UI shows the existing
490
+ // state; re-queuing an exhausted delivery is `replayFromDlq`, not
491
+ // `retry`. A successfully-delivered row is likewise a no-op.
492
+ var attempts = Number(d.attempts || 0);
493
+ if (attempts >= MAX_ATTEMPTS || d.delivered_at != null) {
494
+ return d;
495
+ }
446
496
  var endpoint = await _getEndpoint(d.endpoint_id);
447
497
  if (!endpoint) return null;
448
498
  return await _attempt(deliveryId, endpoint, d.event_type, d.payload_json);