Skip to content

feat: validate refund path on invoice cancellation - #313

Open
blizzj wants to merge 1 commit into
WHEELBACK:mainfrom
blizzj:feature/add-invoice-cancellation-refund-path-validation
Open

feat: validate refund path on invoice cancellation#313
blizzj wants to merge 1 commit into
WHEELBACK:mainfrom
blizzj:feature/add-invoice-cancellation-refund-path-validation

Conversation

@blizzj

@blizzj blizzj commented Jul 29, 2026

Copy link
Copy Markdown

Summary

cancel_invoice and cancel_invoiced previously rejected any cancellation attempt on a non-Pending invoice
with a generic error, leaving funds permanently stuck in escrow with no valid state transition when an
invoice had already been paid.

This PR replaces the single status guard with an exhaustive match over all invoice statuses, ensuring
every cancellation attempt either succeeds with a clear state transition or fails with a meaningful error.

Status transition table

┌────────────────────────┬────────────────────────┬────────────────────────────────────────────────────┐
│ Status at cancellation │ Before │ After │
├────────────────────────────────┼────────────────────────┼────────────────────────────────────────────┤
│ Pending │ ✅ → Cancelled │ ✅ → Cancelled (unchanged) │
├────────────────────────────────┼────────────────────────┼────────────────────────────────────────────┤
│ Paid │ ❌ Error (funds stuck) │ ✅ → RefundRequested (opens refund path) │
├────────────────────────────────┼────────────────────────┼────────────────────────────────────────────┤
│ RefundRequested │ ❌ Generic error │ ❌ AlreadyRefundRequested (refund already │
│ │ │ in progress) │
├────────────────────────────────┼────────────────────────┼────────────────────────────────────────────┤
│ Expired / Released / Cancelled │ ❌ Generic error │ ❌ NotPending / InvoiceCancelled (terminal │
│ │ │ state, clear error) │
└────────────────────────────────┴────────────────────────┴────────────────────────────────────────────┘

When a Paid invoice is cancelled, the status transitions to RefundRequested, feeding directly into the
existing release_escrow flow so funds can be returned without any additional plumbing.

Changes

contracts/invoice/src/lib.rs

  • Added AlreadyRefundRequested = 11 error variant (filling the gap between NotPaid = 10 and
    AmountPrecision = 12)
  • Rewrote cancel_invoice to use an exhaustive match over InvoiceStatus

COMEBACKHERE-contracts/contracts/invoice/src/lib.rs

  • Rewrote cancel_invoiced to use an exhaustive match over InvoiceStatus
  • Paid → RefundRequested transition emits the invoice_refund_req event (consistent with request_refund)
  • Added missing caller.require_auth() call (the original skipped Soroban auth enforcement for the caller
    entirely)

Tests added

contracts/invoice (4 new tests):

  • test_cancel_pending_invoice_no_fund_movement — Pending cancellation sets Cancelled, no fund movement
  • test_cancel_paid_invoice_transitions_to_refund_requested — core regression: Paid → RefundRequested
  • test_cancel_refund_requested_invoice_returns_already_refund_requested — idempotency guard
  • test_cancel_expired_invoice_returns_not_pending — terminal state guard

COMEBACKHERE-contracts/invoice (7 new tests):

  • test_cancel_pending_invoice_no_fund_movement
  • test_cancel_pending_invoice_by_customer_succeeds — customer is also an authorised canceller
  • test_cancel_paid_invoice_transitions_to_refund_requested
  • test_cancel_refund_requested_invoice_returns_already_refund_requested
  • test_cancel_already_cancelled_invoice_returns_invoice_cancelled
  • test_cancel_by_stranger_returns_unauthorized
  • test_cancel_nonexistent_invoice_returns_not_found
  • test_cancel_when_paused_returns_contract_paused

Testing

cargo test --package invoice

⚠️ cargo is not available in the authoring environment. CI (ci-contracts.yml) will provide the full test
output.

closes #189

- cancel_invoice (contracts/invoice): Paid invoices now transition to
  RefundRequested instead of being rejected, preventing funds from
  getting stuck. Added AlreadyRefundRequested error variant (= 11) for
  the case where a refund is already in progress. Terminal states
  (Expired, Released, Cancelled) return NotPending.

- cancel_invoiced (COMEBACKHERE-contracts/invoice): Same match-based
  status-transition guard. Paid → RefundRequested (emits
  invoice_refund_req event). AlreadyRefundRequested returned when
  refund already open. Terminal states return InvoiceCancelled. Also
  adds missing caller.require_auth() enforcement.

Tests added (contracts/invoice):
  - test_cancel_pending_invoice_no_fund_movement
  - test_cancel_paid_invoice_transitions_to_refund_requested
  - test_cancel_refund_requested_invoice_returns_already_refund_requested
  - test_cancel_expired_invoice_returns_not_pending

Tests added (COMEBACKHERE-contracts/invoice):
  - test_cancel_pending_invoice_no_fund_movement
  - test_cancel_pending_invoice_by_customer_succeeds
  - test_cancel_paid_invoice_transitions_to_refund_requested
  - test_cancel_refund_requested_invoice_returns_already_refund_requested
  - test_cancel_already_cancelled_invoice_returns_invoice_cancelled
  - test_cancel_by_stranger_returns_unauthorized
  - test_cancel_nonexistent_invoice_returns_not_found
  - test_cancel_when_paused_returns_contract_paused
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@blizzj Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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.

Add invoice cancellation refund path validation

1 participant