# Partial refunds supported and recorded

- **Pattern:** `ab-001143` (`ecommerce-order-management.cancellation-refunds.partial-refunds`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001143
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001143)

## Why it matters

All-or-nothing refunds create a real business problem: a customer receives a damaged item among a multi-item order and is entitled to a partial refund. If the refund handler always calls `stripe.refunds.create()` with no `amount` parameter, the only option is a full refund — over-refunding destroys margin. The iso-25010:2011 functional correctness gap here is the absence of an `amount` field on the refund interface, and the GDPR Art. 17 dimension is the inability to correctly reflect the agreed financial correction in the stored record. The validation preventing total refunds from exceeding order total is equally important — without it, a bug or a race condition can issue more money than was collected.

## Severity rationale

Medium because all-or-nothing refunds force over-refunding in partial-damage scenarios and prevent accurate financial reconciliation across multi-item orders.

## Remediation

Accept an `amount` parameter in your refund handler at `app/api/orders/[id]/refund/route.ts`, validate it against the refundable balance, and pass it explicitly to the payment provider.

```ts
const alreadyRefunded = order.refunds.reduce((sum, r) => sum + r.amount, 0)
const maxRefundable = order.total - alreadyRefunded

if (amount > maxRefundable) {
  return Response.json(
    { error: `Refund amount exceeds refundable balance of ${maxRefundable}` },
    { status: 400 }
  )
}

const providerRefund = await stripe.refunds.create({
  payment_intent: order.paymentIntentId,
  amount, // explicit partial amount in cents
})
```

## Detection

- **ID:** `partial-refunds`
- **Severity:** `medium`
- **What to look for:** If refunds are supported, check whether the refund logic allows partial refunds — a refund amount that is less than the full order total. Count the number of refund-related fields in the data model: look for an `amount` field, a `currency` field, and a running total or sum mechanism. Check whether the refund handler accepts an `amount` parameter or always uses the full charge. Also check whether validation exists preventing the total refunded amount from exceeding the order total — quote the exact validation expression if found.
- **Pass criteria:** The refund system supports partial refunds. An `amount` field exists on the refund model and is used when calling the payment provider. Multiple refund records can exist per order (the schema allows at least 1 refund-to-order relationship). There is validation preventing the total refunded amount from exceeding the order total. A refund handler that always refunds `order.total` with no `amount` parameter does not count as pass.
- **Fail criteria:** Refunds are all-or-nothing only — the refund logic always refunds the full order amount and does not accept an amount parameter (0 amount fields in the refund interface). No support for issuing a partial refund.
- **Skip (N/A) when:** The project does not support refunds at all (covered by the refund-status-separate check). Or the project only sells single-item orders where a partial refund is not a meaningful concept. No refund handler exists.
- **Detail on fail:** `"The refund handler at src/app/api/orders/[id]/refund/route.ts always calls stripe.refunds.create({ payment_intent: order.paymentIntentId }) with no amount parameter, which refunds the full charge. 0 partial refund support exists."`
- **Remediation:** Accept and validate an amount parameter in your refund handler at `app/api/orders/[id]/refund/route.ts`:

  ```ts
  // app/api/orders/[id]/refund/route.ts
  export async function POST(req: Request, { params }) {
    const { amount, reason } = await req.json()
    const order = await db.orders.findUnique({
      where: { id: params.id },
      include: { refunds: true },
    })

    const alreadyRefunded = order.refunds.reduce((sum, r) => sum + r.amount, 0)
    const maxRefundable = order.total - alreadyRefunded

    if (amount > maxRefundable) {
      return Response.json(
        { error: `Refund amount exceeds refundable balance of ${maxRefundable}` },
        { status: 400 }
      )
    }

    // Pass amount to payment provider
    const providerRefund = await stripe.refunds.create({
      payment_intent: order.paymentIntentId,
      amount, // partial refund amount in cents
    })

    await db.refund.create({
      data: { orderId: params.id, amount, reason, providerRefundId: providerRefund.id, status: 'processing' },
    })

    return Response.json({ success: true })
  }
  ```

## External references

- iso-25010 functional-correctness
- gdpr Art. 17

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-001143
