# Cancellation updates order status and creates history entry

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

## Why it matters

A cancellation that only flips the `status` field leaves no durable record of who cancelled, when, or why. This is a CWE-778 gap and a GDPR Art. 5(1)(f) issue: processing activities must be accountable, and 'the order is cancelled' with no supporting event log is not accountable. Without a history entry, support staff cannot determine whether the cancellation was customer-initiated or admin-initiated, and refund disputes have no audit anchor. Both writes — status update and history entry — must be atomic in a transaction; a non-transactional write creates a race condition where one operation can succeed and the other fail.

## Severity rationale

High because a cancellation with no history entry eliminates the audit trail needed for refund disputes, compliance checks, and post-incident support investigations.

## Remediation

Route all cancellations through the shared `transitionOrder` function in `lib/orders/state-machine.ts`, which handles both the status update and history entry atomically. Remove any direct `db.orders.update()` calls in cancellation handlers.

```ts
// Replace in app/api/orders/[id]/cancel/route.ts:
// WRONG:
await db.orders.update({ where: { id: orderId }, data: { status: 'cancelled' } })

// CORRECT:
await transitionOrder(orderId, 'cancelled', actorId, 'Customer requested cancellation')
// transitionOrder wraps both writes in a db.$transaction
```

## Detection

- **ID:** `cancellation-history`
- **Severity:** `high`
- **What to look for:** When an order is cancelled, verify that at least 2 write operations happen atomically: (1) the order's status field is updated to `cancelled`, and (2) a history entry is created recording the cancellation event. Count the number of database writes in the cancellation handler. Check whether the cancellation handler calls the same `transitionOrder` function used for other status changes (which would guarantee both actions), or whether it has its own ad-hoc implementation. Quote the exact function call used for cancellation if it bypasses the shared transition function.
- **Pass criteria:** Cancellation logic updates order status to `cancelled` AND creates a history/audit log entry — at least 2 writes occur. Both actions occur atomically (in a database transaction) so that a partial write cannot result in a cancelled order with no history or a history entry for an order whose status was not actually updated. A cancellation that only calls `db.orders.update()` with no history write does not count as pass.
- **Fail criteria:** Cancellation only updates the status field without creating a history entry (only 1 write). Or cancellation creates a history entry but fails to update the status field. Or both actions occur but are not wrapped in a transaction, creating a race condition risk.
- **Skip (N/A) when:** The project has no cancellation feature (covered by the preshipment-cancellation check, which would also be skipped). Or the project delegates all order management to an external platform that handles its own audit logging.
- **Detail on fail:** `"The cancellation handler at src/app/api/orders/[id]/cancel/route.ts updates order.status to 'cancelled' directly via db.orders.update() but does not create an order_history entry. Only 1 write operation occurs, not 2."`
- **Remediation:** Ensure all cancellation paths go through the shared `transitionOrder` function in `lib/orders/state-machine.ts` that atomically writes both the status update and the history entry. If you have a separate cancellation code path that bypasses `transitionOrder`, refactor it:

  ```ts
  // Instead of (in app/api/orders/[id]/cancel/route.ts):
  await db.orders.update({ where: { id: orderId }, data: { status: 'cancelled' } })

  // Use:
  await transitionOrder(orderId, 'cancelled', actorId, 'Customer requested cancellation')
  // transitionOrder handles both the status update AND the history entry in a transaction
  ```

  If you also need to capture a `cancelledAt` timestamp, do it inside `transitionOrder` alongside the other lifecycle timestamps.

## External references

- cwe CWE-778
- iso-25010 functional-correctness
- gdpr Art. 5(1)(f)

Taxons: data-integrity, observability

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