@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 +2 -0
- package/lib/admin.js +48 -2
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
})
|
|
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
|
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED