Skip to content

Fix swallowed deposit-status refusals and closing manual refunds in the B2B refund path - #1415

Merged
ebma merged 7 commits into
stagingfrom
fix/monerium-refund-status-closure
Oct 8, 2026
Merged

ebma merged 7 commits into
stagingfrom
fix/monerium-refund-status-closure

Conversation

@ebma

@ebma ebma commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

What this fixes

Two bugs in the Monerium B2B refund path that were already on staging. The vortex-review of #1411 found them.

1. Refused deposit-status changes were swallowed (recovery.ts)

  • Processed redeem order: the recovery was marked redeemed before setDepositStatus(refunded). The refusal string was ignored, and the "refunded" info log was written either way. If that call threw or was refused, the recovery dropped out of every open query. The deposit then stayed recovering forever and DEPOSIT_RETURNED never fired.
  • fail(): it wrote error before parking the deposit and ignored the refusal. A refused park left error set on a deposit that was still recovering. The orchestrator reads that as an operator retry, so it reset the attempts and the five-attempt cap no longer applied.

Fix: change the deposit first. The recovery is marked redeemed, or gets its error on a park, only after the transition succeeded. On a refusal, an error is logged and the recovery stays open, so the next cycle retries it. If the transition throws, nothing is written and the orchestrator's catch logs it. RecoveryDeps.setDepositStatus is now typed Promise<string | null>, which is what the live function already returned.

2. A manual refund could not be closed with the documented PATCH

Some refunds stay manual: EUR 15,000 and above, or a deposit with no payer. These park as recovery_failed at topped_up. The lattice only allowed recovery_failed -> recovering, so the runbook's PATCH .../status {"status": "refunded"} got a 409. Moving the deposit to recovering instead made the next cycle treat it as an operator retry and park it again. The deposit never reached refunded, and that client's later refunds stayed blocked. If the order placed by hand did not carry the vortex-refund: memo, a retried redeem could also place a second order.

Fix: add recovery_failed -> refunded to the lattice. Only two callers ever request refunded: the admin PATCH and the processed-order branch, and both are correct from recovery_failed. The orchestrator already closes an open recovery whose deposit is refunded without placing an order, so nothing else changes. The admin route keeps its status codes and messages for every existing transition. The one change is that recovery_failed -> refunded now returns 200 instead of 409.

The runbook now gives the procedure that works. For a refund that stays manual: don't set recovering. Place the order by hand under the memo vortex-refund:<depositId>, then close the deposit directly from recovery_failed with refunded.

Tests

Every new test fails on the old code and passes with the fix.

  • recovery.test.ts, driveRecovery: a processed order with a setDepositStatus that returns a refusal, then one that throws. The recovery stays redeeming and no "refunded" log appears. A later successful transition closes it.
  • recovery.test.ts, orchestrator on a real database: the fifth failed attempt hits a refused park, then a throwing one. error stays empty and the attempt count goes 5 -> 6 -> 7, with no reset to a fresh run. The deposit stays recovering.
  • recovery.test.ts, orchestrator: an EUR 20,000 refund parks as recovery_failed ("supporting document"). The operator closes it with the real setDepositStatus(refunded), and the next cycle marks the recovery redeemed. The deposit ends refunded, no redeem order is placed, and activeRecoveryExists() is false.
  • moneriumB2b.controller.test.ts: PATCH refunded on a recovery_failed deposit returns 200 and the deposit becomes refunded.
  • deposit-processor.test.ts: lattice assertion for the new edge.

Gates: the B2B, workers and controllers tests pass on an isolated test database (398 pass). bun run typecheck, bun run verify and bun run wire-contract:check are clean, and the snapshot is unchanged.

Docs

  • Security spec (05-integrations/monerium-b2b.md): the lattice invariant, the refund invariant (status-first ordering, closing a manual refund) and the test checklist.
  • The architecture and product state diagrams gain the recovery_failed --> refunded edge.
  • Runbook §2.7: the manual-refund procedure; the memo for a hand-placed order is now vortex-refund:<depositId>; step 6 says the close works from recovering or recovery_failed.

Merge notes

Review follow-up (vortex-review)

The review found no P0 or P1. Fixed here: the one P2 and the cheap P3s.

  • Rejected redeem orders go to the manual close (P2). The runbook called a rejected order "fixable" with recovering, but that retry re-reads the same order, or adopts it again by memo, and parks the deposit again. §2.7 and the §3 REFUND FAILED row now retry only after five failed attempts. A rejected order joins the cases the operator refunds by hand and closes as refunded.
  • No rejected order id after a hand close. A rejected order's id is dropped from the recovery and kept only in error, so DEPOSIT_RETURNED after a hand close never names an order that paid nothing. docs/api/pages/07-webhooks.md now says redeemOrderId is null for a refund completed by hand.
  • REFUND FAILED only once parked. The line is logged only after the park succeeds. The refusal line carries the reason, and both new error lines have a §3 triage row.
  • Docs: the §8.8 sandbox row now says to empty the refund wallet before closing a no-payer park. Spec invariant 7 now names the refund path in recovery.ts. The spec checklist covers the hand close and no longer claims the status PATCH refuses pending executions (only the recover POST does).
  • Tests:
    • A DB-backed orchestrator test: a rejected order re-parks on an operator retry, the hand close ends redeemed with redeemOrderId null, and only one redeem order is ever placed.
    • The refused-park test now also asserts that REFUND FAILED is not logged until the park succeeds.
    • Both fail on the previous code.
    • Both log spies are scoped against a logger mock leaked from another test file.

Not changed (P3, left for later): refundedAt is derived from recovery.updatedAt, a narrow crash window in fail(), no balance check on a hand close, an empty payerIbanMasked for a no-payer hand close, and the memo as the payer-visible reference.

Gates: the B2B, workers and controllers tests pass on an isolated database (399). typecheck, verify and wire-contract:check are clean.

@netlify

netlify Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 4430df4
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6ac7c9a5fd1052000868c426
😎 Deploy Preview https://deploy-preview-1415--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

Name Link
🔨 Latest commit 4430df4
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6ac7c9a5fd056a00084a934f

@netlify

netlify Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit 4430df4
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6ac7c9a5ae128000080a1f68

ebma added 7 commits October 8, 2026 18:35
The processed-order branch marked the recovery redeemed before setting the
deposit refunded and ignored the refusal, so a refused or failed transition
left the deposit recovering forever with no DEPOSIT_RETURNED. fail() ignored
the refusal too, and the error it had already written turned a refused park
into an operator retry that reset the five-attempt cap. Change the deposit
first and only record the outcome once it succeeded.
A refund that stays manual (EUR 15,000 and above, or no payer) parks as
recovery_failed, but the lattice only allowed recovery_failed -> recovering,
so the documented PATCH refunded was refused. Moving it to recovering made the
next cycle reset its attempts and park it again, so the deposit never reached
refunded and the client's later refunds stayed blocked. Allow the edge; the
orchestrator already closes a recovery whose deposit is refunded without
placing an order. The runbook now has the operator place the order under the
vortex-refund memo and close straight from recovery_failed.
The line points the operator at the manual procedure, but it fired before
the park and again every cycle while the park was refused, when the keeper
was still driving the refund. The refusal line now carries the reason, and
both new error lines get a triage row.
A retry after a rejected redeem order re-reads the same order, or adopts it
again by memo, and parks the deposit again, yet the runbook called it a
fixable retry. Only five failed attempts are retried now; the rejection joins
the parks the operator refunds by hand. A rejected order's id is dropped from
the recovery so DEPOSIT_RETURNED for that hand close never names an order
that paid nothing.
Invariant 7 still said only the keeper and the admin endpoints move a
deposit along the refund branch, the checklist claimed the status PATCH
refuses pending executions (only the recover POST does) and missed the hand
close, and the sandbox row closed a no-payer park without saying the refund
wallet must be empty first.
Another file leaves logger methods as bun mocks, so spyOn returns the
existing mock with its earlier calls and the log assertions count lines
from previous tests.
Staging's newer refund tests stub setDepositStatus without the refusal result this branch introduced, and one of this branch's tests checked the in-flight recover globally; activeRecoveryExists is per client since the per-client refunds landed.
@ebma
ebma force-pushed the fix/monerium-refund-status-closure branch from 70e575c to 4430df4 Compare October 8, 2026 16:49
@ebma
ebma merged commit 053d8cd into staging Oct 8, 2026
6 checks passed
@ebma
ebma deleted the fix/monerium-refund-status-closure branch October 8, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant