Refund status tracked separately from order status
Why it matters
When refund state is encoded only as order.status = 'refunded', the system cannot represent intermediate states: a refund that was requested but not yet processed, a refund that the payment provider rejected, or an order that was cancelled without a refund being issued at all (e.g., store credit was given instead). This is an iso-25010:2011 functional correctness failure and a GDPR Art. 17 (right to erasure / financial correction) accountability gap. Separate refund tracking also enables storing the provider's refund ID — essential for reconciling Stripe refunds against your own records and for debugging partial or failed refunds.
Severity rationale
High because a single 'refunded' status value cannot distinguish between pending, processing, completed, or failed refund states, making financial reconciliation and dispute resolution unreliable.
Remediation
Create a separate Refund model in prisma/schema.prisma with its own independent status lifecycle, and initiate refunds through a dedicated function in lib/refunds/create.ts.
// prisma/schema.prisma:
// model Refund {
// id String @id @default(cuid())
// orderId String
// amount Int // cents
// status String // pending | processing | completed | failed
// providerRefundId String?
// reason String?
// initiatedBy String?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// order Order @relation(fields: [orderId], references: [id])
// }
export async function initiateRefund(orderId: string, amount: number, reason: string, actorId: string) {
return db.refund.create({
data: { orderId, amount, status: 'pending', reason, initiatedBy: actorId },
})
}
Detection
-
ID:
refund-status-separate -
Severity:
high -
What to look for: Enumerate all refund-related fields and tables in the database schema. Check for a separate
refundstable orrefund_statusfield that is distinct from the order'sstatusfield. Examine the Prisma schema (prisma/schema.prisma), Drizzle schema, or migration files for arefundsmodel. Count the number of refund-specific status values available (at least 2 distinct statuses required: e.g.,pending,completed). Also check whether the payment provider (Stripe, etc.) refund IDs are stored. The key distinction: can an order be incancelledstatus while simultaneously having a refund inprocessingstate? If refund state is only represented by settingorder.status = 'refunded', that is a design gap. -
Pass criteria: Refunds are tracked in a separate field or table with at least 2 distinct status values (e.g.,
refund_status: 'pending' | 'processing' | 'completed' | 'failed'). The order's status and the refund's status are independent — an order can becancelledwhile the refund is stillprocessing. If Stripe or another payment provider is used, the provider's refund ID is stored. A singlerefundedflag on the order (boolean or status value) does not count as pass — separate lifecycle tracking is required. -
Fail criteria: Refund state is only represented by the order status field (e.g.,
order.status = 'refunded') or a single boolean flag (0 separate refund statuses). There is no way to distinguish between an order that was cancelled with no refund issued, an order where a refund was requested but not yet processed, or an order where the refund completed successfully. -
Skip (N/A) when: The project explicitly does not support refunds — all payments are final and non-refundable, and no refund-related code exists in the codebase.
-
Detail on fail:
"No separate refund tracking mechanism found. The only refund-related state is represented by setting order.status to 'refunded'. 0 separate refund status values exist. No refunds table, no refund_status field, and no payment provider refund ID stored." -
Remediation: Create a refunds table with independent status tracking in
prisma/schema.prisma:// Prisma schema (prisma/schema.prisma): // model Refund { // id String @id @default(cuid()) // orderId String // amount Int // in cents // currency String @default("usd") // status String // pending | processing | completed | failed // providerRefundId String? // Stripe refund ID, etc. // reason String? // initiatedBy String? // userId of admin or 'system' // createdAt DateTime @default(now()) // updatedAt DateTime @updatedAt // order Order @relation(fields: [orderId], references: [id]) // } // lib/refunds/create.ts export async function initiateRefund( orderId: string, amount: number, reason: string, actorId: string ) { const refund = await db.refund.create({ data: { orderId, amount, status: 'pending', reason, initiatedBy: actorId }, }) // Process with payment provider asynchronously await queueRefundProcessing(refund.id) return refund }
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