Partial refunds supported and recorded
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.
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
amountfield, acurrencyfield, and a running total or sum mechanism. Check whether the refund handler accepts anamountparameter 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
amountfield 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 refundsorder.totalwith noamountparameter 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:// 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:2011 · functional-correctness — Functional Correctness
- gdpr · Art. 17 — Right to erasure / refund processing obligation
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ecommerce-order-management·automated