@blamejs/blamejs-shop 0.4.41 → 0.4.42

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.42 (2026-06-13) — **A return refund is now recorded against the order, so it can't be paid out a second time from the order console.** Issuing a provider refund for a return moved the money but never recorded it against the order, so order.refundedTotalMinor didn't count it. The order console's refund caps each refund at the order's remaining un-refunded balance — but with the return refund invisible to that balance, an operator could refund the full order total a second time from the order screen, paying the customer back twice. A return's provider refund now writes to the order's refund ledger, stamped with the provider refund id so the entry collapses with the payment provider's own refund webhook — whether the console or the webhook records it first, the same refund is counted exactly once. The order's move to a fully-refunded state and the gift-card / loyalty reversals remain driven by the refund webhook, as before. No migration to apply. **Fixed:** *A return refund now counts against the order's refunded total* — When the returns console issues a provider refund, the amount is now recorded against the order through the same deduplicating ledger path a partial console refund uses, stamped with the provider refund id. Previously the money moved but the order's refunded total never saw it, so the order console's over-refund cap — which limits a refund to the order's remaining balance — could be cleared again and pay the customer back a second time. Keying the entry on the provider refund id makes it idempotent against the provider's refund webhook: whichever path records first, a refund mirrored by both is counted exactly once. The order's terminal refunded state and the gift-card / loyalty reversals stay driven by the refund webhook.
12
+
11
13
  - v0.4.41 (2026-06-13) — **Subscription billing no longer double-charges on a retried period close or a concurrent immediate plan change.** Two billing paths could invoice the same charge twice. A metered-usage period close is cron-driven and at-least-once, but it enqueued its roll-up invoice with no idempotency key, so a retried tick — a transient error mid-run, an at-least-once redelivery, or an operator re-running the close — wrote a second invoice for the same period and billed the customer twice. And an immediate plan change recorded its proration adjustment after a guard that only blocked a queued (pending) change, so two concurrent immediate executions both transitioned the subscription and both recorded the proration. The period close now stamps a deterministic per-period idempotency key so a replay returns the existing invoice, and the immediate plan change now transitions the subscription with a conditional update so only the one call that actually moves the plan records the proration. No migration to apply. **Fixed:** *A retried metered-usage period close no longer bills the period twice* — Rolling a usage period into an invoice now carries a deterministic idempotency key derived from the subscription, period window, and currency. A period close that runs more than once — a cron retry, an at-least-once redelivery, or a manual re-run — now returns the invoice already recorded for that period instead of enqueuing a duplicate, so the customer is billed for the period exactly once. · *Concurrent immediate plan changes charge proration once* — Executing an immediate plan change now transitions the subscription with a conditional update keyed on the current plan, and records the proration adjustment only when that update actually moves the plan. The prior guard only refused a queued change, so two immediate executions racing the same change — a double-submit or a retry — could both apply the transition and both record the proration. Now only the one call that wins the transition charges it.
12
14
 
13
15
  - v0.4.40 (2026-06-13) — **A payment webhook whose first delivery fails is reprocessed on redelivery instead of dropping the refund or capture.** The Stripe and PayPal webhook handlers claim an event id for replay suppression the moment the signature verifies — before processing — so a duplicate delivery can never apply a refund or a state change twice. But the claim was kept even when processing then FAILED: a transient database error, a garbled or unparseable refund amount, or an illegal state transition would make the handler return a 5xx so the provider redelivered — and the redelivery found the already-claimed id and was dropped as a replay, permanently losing the refund or the capture-to-paid transition. The claim is now released whenever processing throws, so the provider's redelivery (carrying a recovered payload, or arriving once a transient blip clears) is reprocessed and the refund or capture finally lands. A delivery that SUCCEEDS still keeps its claim, so genuine duplicate deliveries remain suppressed and a refund is never applied twice. No migration to apply. **Fixed:** *Webhook redelivery recovers a refund or capture a failed first delivery would have lost* — Replay suppression on the Stripe and PayPal webhook handlers now reserves the event id, commits the claim only when processing succeeds, and releases it when processing throws. Previously the id was claimed eagerly and kept regardless of outcome, so any first delivery that failed downstream — a transient store error, a refund amount that couldn't be parsed, an out-of-order event that hit an illegal transition — turned the provider's at-least-once redelivery into a silently dropped replay, and the refund or the move to paid never reached the order ledger. The provider's redelivery is now reprocessed, while a delivery that already succeeded still suppresses true duplicates so a refund is never double-applied.
package/lib/admin.js CHANGED
@@ -5165,17 +5165,19 @@ function mount(router, deps) {
5165
5165
  });
5166
5166
  var idem = "rma-refund:" + rma.id;
5167
5167
  var refund;
5168
+ var providerRefund;
5168
5169
  try {
5169
5170
  // Routed by the linked order's provider (Stripe payment_intent vs
5170
5171
  // PayPal capture) — see _issueProviderRefund. The deterministic key
5171
5172
  // keeps a retry of the SAME RMA refund deduplicated at either
5172
5173
  // provider (Stripe Idempotency-Key / PayPal-Request-Id).
5173
- refund = (await _issueProviderRefund(order2, {
5174
+ providerRefund = await _issueProviderRefund(order2, {
5174
5175
  amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
5175
5176
  reason: "requested_by_customer",
5176
5177
  metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
5177
5178
  idempotency_key: idem,
5178
- })).raw;
5179
+ });
5180
+ refund = providerRefund.raw;
5179
5181
  } catch (e) {
5180
5182
  // Provider call failed — release the claim so the operator can retry
5181
5183
  // a transient failure. A release failure can't be recovered here, so
@@ -5184,6 +5186,50 @@ function mount(router, deps) {
5184
5186
  catch (_releaseErr) { /* drop-silent — the original provider error below is the actionable one */ }
5185
5187
  throw e;
5186
5188
  }
5189
+ // Record the refund into the ORDER ledger so order.refundedTotalMinor
5190
+ // reflects the RMA money. Without this, the money moved at the provider
5191
+ // but the order ledger never saw it, so the direct-refund console's
5192
+ // over-refund cap (which limits a refund to the order's remaining
5193
+ // un-refunded balance) could pay out the order total a SECOND time. The
5194
+ // provider refund id is stamped via the shared _refundLedgerMeta, so the
5195
+ // delta/refund-id dedupe collapses this with the charge.refunded webhook
5196
+ // mirror of the SAME refund (no double count). Mirrors the console
5197
+ // partial-refund path: a balance-clearing refund drives the terminal edge
5198
+ // (gift-card / loyalty reversals fire); otherwise a ledger row is
5199
+ // appended. Best-effort — a record failure is audited and backstopped by
5200
+ // the webhook mirror, never undoing a refund whose money already moved.
5201
+ try {
5202
+ var rmaRefundedMinor = (Number.isInteger(providerRefund.amount_minor) && providerRefund.amount_minor > 0)
5203
+ ? providerRefund.amount_minor
5204
+ : ((rma.refund_amount_minor != null && rma.refund_amount_minor > 0)
5205
+ ? rma.refund_amount_minor
5206
+ : (Number(order2.grand_total_minor) || 0));
5207
+ // recordPartialRefund appends the refund row keyed on the provider
5208
+ // refund id and dedupes ON that id (atomic conditional insert), so it
5209
+ // is safe whether the console reaches the ledger first OR the provider's
5210
+ // charge.refunded webhook mirrors the SAME refund first — the amount is
5211
+ // counted exactly once. The order's terminal 'refunded' edge and the
5212
+ // gift-card / loyalty reversals are driven by the webhook mirror when
5213
+ // the cumulative refund clears the charge (the path that already owns
5214
+ // them), so the RMA path never drives the terminal edge — that path does
5215
+ // NOT dedupe refund ids, and a webhook-first refund would make it
5216
+ // double-count and wrongly mark the order fully refunded.
5217
+ await order.recordPartialRefund(order2.id, {
5218
+ amount_minor: rmaRefundedMinor,
5219
+ reason: "rma:" + (rma.rma_code || rma.id),
5220
+ metadata: _refundLedgerMeta(providerRefund, { rma_id: rma.id }),
5221
+ });
5222
+ } catch (_ledgerErr) {
5223
+ b.audit.safeEmit({
5224
+ action: AUDIT_NAMESPACE + ".returns.refund.ledger_gap", outcome: "failure",
5225
+ metadata: {
5226
+ rma_id: rma.id,
5227
+ order_id: order2.id,
5228
+ refund_id: providerRefund.id || null,
5229
+ err: (_ledgerErr && _ledgerErr.message) ? _ledgerErr.message : String(_ledgerErr),
5230
+ },
5231
+ });
5232
+ }
5187
5233
  return { refund: refund, rma: claimed };
5188
5234
  }
5189
5235
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.41",
2
+ "version": "0.4.42",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.41",
3
+ "version": "0.4.42",
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": {