fix(payment,returns): refunds are measured against a payment and settled under REFUNDS_APPROVE

Merged
EV
11d ago
+326-33

Fixes review R3-08 and R3-09 from the #10254 deep review (re-traced: still open on develop 77606f6199).

What was wrong

pathfailure
R3-08POST /refunds / GraphQL createRefund → RefundService.createRefunda refund naming only a returnId/claimId and no paymentId was measured against nothing, at creation and at approval; orderId/returnId not checked against each other. POST /refunds {orderId, returnId, amount:"100000"} + /approve → a SUCCEEDED refund of 100,000 on a 20.00 return, repeatable.
R3-09/order-returns/:id/receive (+refund), /order-returns/:id/refund, claim approval → ReturnRefundServicerecords and settles the refund under RETURNS_RECEIVE / CLAIMS_RESOLVE alone — the payment domain's own rule (REFUNDS_APPROVE is separate because a refund is somebody else's decision) did not hold; /refund had no retry key, so a retry after a lost answer refunded twice.

The fix

  • Refund API requires a payment: createRefund refuses a refund that names no payment (REFUND_PAYMENT_REQUIRED) and an order that is not the payment's order (REFUND_ORDER_MISMATCH); approveRefund refuses a payment-less refund (REFUND_PAYMENT_REQUIRED). Payment-less refunds are recorded and settled only by the return/claim flow through createAttributedRefund / approveAttributedRefund, which measures the amount first (existing headroom rule).
  • Settling needs REFUNDS_APPROVE: ReturnRefundService.assertMayRefund() refuses before anything is read or written (REFUND_APPROVAL_REQUIRED, 403). The returns domain asks it through IRefundGatewayPort.assertMayRefund before a receipt that also refunds moves the goods, so a refusal never leaves goods received under an error answer.
  • Retry key: POST /order-returns/:id/refund and GraphQL refundOrderReturn carry @Idempotent({ scope: 'return.refund', required: true }) as §7.14 states; the SDL description and the schema snapshot are updated (description-only; schema gate: unchanged).

Intended behaviour changes: POST /refunds without paymentId → 400; approving a payment-less refund via the refund API → 400; a return/claim refund by a user without REFUNDS_APPROVE (default: SUPER_ADMIN only) → 403; /order-returns/:id/refund now requires an Idempotency-Key header (GraphQL: the key argument). A per-role refund limit ("above an agent's limit") is not implemented anywhere yet — until it is, settling a refund is the approver's act.

Evidence

  • Red on develop: 4 new cases in return-refund.service.spec.ts (settle without approval; payment-less API refund; order mismatch; payment-less approval) fail on develop's services; the payment-backed control passes.
  • Green: payment 762/762, returns 433/433 (4 refund-service cases moved to the attributed path; the GraphQL/REST parity spec now asserts equal retry keys); all 35 static gates; schema snapshot gate ok; cspell clean; tsc clean for both plugins.

Tracked in the homelab handover HANDOVER-2026-10-15-gauzy-followups.md (Q6 #1–#2, Q5).

🤖 Generated with Claude Code


Summary by cubic

Fixes review findings R3-08 and R3-09. The refund API previously accepted a refund naming only a return or claim without a payment, so amounts were measured against nothing; the return/claim flow settled refunds under the returns grant alone. Refunds now always tie to an order's payment through the refund API, and settling any refund requires REFUNDS_APPROVE, checked before anything is read or written. The refund route and its GraphQL field require a return.refund idempotency key, and RefundOrderReturnInput now carries it, so a GraphQL refund without a key is refused before the resolver runs.

  • R3-08: the refund API refuses a refund with no paymentId (REFUND_PAYMENT_REQUIRED) and an order that isn't the payment's order (REFUND_ORDER_MISMATCH), and won't approve a payment-less refund; those are recorded and settled only by the return/claim flow via createAttributedRefund/approveAttributedRefund, which measure the amount against headroom first.
  • R3-09: ReturnRefundService checks REFUNDS_APPROVE before reading or writing anything, and a receipt that also refunds calls IRefundGatewayPort.assertMayRefund before goods move, so a refusal leaves the receipt unwritten.

Behavior changes

  • POST /refunds without paymentId → 400; approving a payment-less refund via the refund API → 400; return/claim refunds without REFUNDS_APPROVE → 403.
  • POST /order-returns/:id/refund and GraphQL refundOrderReturn require an idempotency key (scope return.refund); REST reads it from the header, GraphQL from the input, which now declares it. Retrying with the same key replays the first refund and refunds once.