# ADR-0007 — Idempotent point awards and atomic loyalty mutations

> Produced by the Reversa **Detective** (phase: interpretation) · doc_level: `complete`
> Retroactive ADR reconstructed from code (`Order`, `Customer`, `OrderController`) and Git history: `fix(customers): make gift redemption atomic under DB::transaction` (`6c717a6`).

- **Status:** Accepted (as-built) 🟢
- **Confidence:** 🟢 CONFIRMED

## Context

Loyalty points touch several rows at once (customer balance, order state, gift pivot/stock). Orders can be reopened and re-finalised, and redemptions can race. Naive writes risk double-crediting points or desyncing gift stock from the customer's point balance.

## Decision

- **Idempotent award:** points are credited to the customer only when an order first becomes `done`, guarded by `orders.points_awarded_at`. Reopen→re-finalise cannot double-award because the timestamp persists.
- **Claw-back on delete:** deleting an order reverses its earned points, clamped `max(0, points - earned_point)`.
- **Atomic redemption:** `redeemRewardPoints` wraps the pivot attach + `gift.used++` + `customer.points--` in `DB::transaction()` with `lockForUpdate` on customer and gift, re-checking availability under the lock (`6c717a6`, 2026-09-17 — previously non-transactional).

## Consequences

- 🟢 Points and gift stock stay consistent under concurrency and failure.
- 🟢 The `points_awarded_at` guard is the single source of truth for "already awarded"; the debt equivalent is `debt_locked` (ADR-0004).
- 🟡 The strict `quantity_available === 0` out-of-stock test still admits a negative-stock edge case in theory; made hard to reach by the `min:used` validation rule and the now-transactional path.
