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.
High because a cancellation with no history entry eliminates the audit trail needed for refund disputes, compliance checks, and post-incident support investigations.
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.
// 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
ID: ecommerce-order-management.cancellation-refunds.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:
// 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.