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.
High because a single 'refunded' status value cannot distinguish between pending, processing, completed, or failed refund states, making financial reconciliation and dispute resolution unreliable.
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 },
})
}
ID: ecommerce-order-management.cancellation-refunds.refund-status-separate
Severity: high
What to look for: Enumerate all refund-related fields and tables in the database schema. Check for a separate refunds table or refund_status field that is distinct from the order's status field. Examine the Prisma schema (prisma/schema.prisma), Drizzle schema, or migration files for a refunds model. 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 in cancelled status while simultaneously having a refund in processing state? If refund state is only represented by setting order.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 be cancelled while the refund is still processing. If Stripe or another payment provider is used, the provider's refund ID is stored. A single refunded flag 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
}