fix(payment,returns): refunds are measured against a payment and settled under REFUNDS_APPROVE
Fixes review R3-08 and R3-09 from the #10254 deep review (re-traced: still open on develop 77606f6199).
What was wrong
| path | failure | |
|---|---|---|
| R3-08 | POST /refunds / GraphQL createRefund → RefundService.createRefund | a 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 → ReturnRefundService | records 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:
createRefundrefuses a refund that names no payment (REFUND_PAYMENT_REQUIRED) and an order that is not the payment's order (REFUND_ORDER_MISMATCH);approveRefundrefuses a payment-less refund (REFUND_PAYMENT_REQUIRED). Payment-less refunds are recorded and settled only by the return/claim flow throughcreateAttributedRefund/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 throughIRefundGatewayPort.assertMayRefundbefore 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/refundand GraphQLrefundOrderReturncarry@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;
tscclean 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 viacreateAttributedRefund/approveAttributedRefund, which measure the amount against headroom first. - R3-09:
ReturnRefundServicechecksREFUNDS_APPROVEbefore reading or writing anything, and a receipt that also refunds callsIRefundGatewayPort.assertMayRefundbefore goods move, so a refusal leaves the receipt unwritten.
Behavior changes
POST /refundswithoutpaymentId→ 400; approving a payment-less refund via the refund API → 400; return/claim refunds withoutREFUNDS_APPROVE→ 403.POST /order-returns/:id/refundand GraphQLrefundOrderReturnrequire an idempotency key (scopereturn.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.