# Refund status tracked separately from order status

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

## 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`.

```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 `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`:

  ```ts
  // 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 functional-correctness
- gdpr Art. 17

Taxons: data-integrity

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