# State Machines — TinyPOS (`tnx-pos`)

> Produced by the Reversa **Detective** (phase: interpretation) · doc_level: `complete`
> Generated on 2026-09-18

**Confidence scale:** 🟢 CONFIRMED (read directly from code) · 🟡 INFERRED (pattern-based, may be wrong) · 🔴 GAP (needs human validation)

The central lifecycle in TinyPOS is the **Order**, which drives two coupled side-effect lifecycles: **loyalty points** and **POS debt**. Reference-data entities (Gift, Product) have simpler active/retired lifecycles. Each machine below lists the states, the transitions with their triggers and guards, and a Mermaid diagram.

---

## 1. Order status

`orders.status` is an enum with two persisted values: `draft` and `done`. Deletion is a terminal exit (hard delete — no soft-delete state). 🟢

### States

- **draft** — a held/parked cart. Recallable in the POS draft picker. No points awarded, no POS debt posted.
- **done** — a finalised sale. Points awarded and any POS debt posted at the moment of entry.
- **(deleted)** — terminal. Row is permanently removed after side effects are reversed.

### Transitions

| From | To | Trigger | Guard / effect | Confidence |
|------|----|---------|----------------|------------|
| ∅ | draft | `store` with `status=draft` (park cart) | Totals computed; no points, no debt. | 🟢 |
| ∅ | done | `store` with `status=done` (immediate sale) | Totals computed; points awarded; `pos_debt` posted if `debt_amount>0`. | 🟢 |
| draft | draft | `update` while editable | Totals recomputed. | 🟢 |
| draft | done | `update` finalises draft (`create_now_mode`) | `is_editable` required; points awarded (once, sets `points_awarded_at`); `pos_debt` posted if `debt_amount>0`. | 🟢 |
| done | draft | `update` reopen | `is_editable` (≤24h since `updated_at`); debt stays `debt_locked` if already posted. | 🟢 |
| done | done | `update` while editable | Recompute; points **not** re-awarded (guarded by `points_awarded_at`); debt frozen if `debt_locked`. | 🟢 |
| draft/done | (deleted) | `destroy` | Transaction: reverse points (clamp ≥0) + void `pos_debt` (clamp by balance) + hard delete. | 🟢 |

### Guards

- **`is_editable`** = `draft` OR (`done` AND now ≤ `updated_at` + `limit_hours_editable` (24h)). Blocks edits/finalisation of stale done orders. 🟢
- **`debt_locked`** = a `pos_debt` ledger row exists for the order. Freezes the debt input; ensures POS debt posts exactly once across reopen cycles. 🟢
- **`points_awarded_at`** set = points already credited; blocks re-award. 🟢

```mermaid
stateDiagram-v2
    [*] --> draft: store(status=draft)\npark cart
    [*] --> done: store(status=done)\naward points + post pos_debt
    draft --> draft: update (editable)\nrecompute totals
    draft --> done: update / create_now_mode\n[is_editable]\naward points (once) + post pos_debt
    done --> draft: update reopen\n[is_editable ≤24h]\ndebt stays locked
    done --> done: update (editable)\nno re-award, debt frozen
    draft --> [*]: destroy\nreverse points + void debt + hard delete
    done --> [*]: destroy\nreverse points + void debt + hard delete
```

---

## 2. Order loyalty-points lifecycle

Points are a side-effect state tracked by `orders.points_awarded_at` (nullable timestamp) against the customer's running `points` balance. 🟢

### States

- **not-awarded** — `points_awarded_at` is null. Customer balance not yet credited for this order.
- **awarded** — `points_awarded_at` set. Customer `points` includes this order's `earned_point`.
- **reversed** — order deleted; earned points clawed back (clamped ≥ 0). Terminal with the order.

### Transitions

| From | To | Trigger | Guard / effect | Confidence |
|------|----|---------|----------------|------------|
| not-awarded | awarded | order becomes `done` | `customer.points += earned_point`; stamp `points_awarded_at`. | 🟢 |
| awarded | awarded | order re-saved while `done` | No-op for points (idempotent via `points_awarded_at`). | 🟢 |
| awarded | reversed | `destroy` | `reversePointsForOrder`: `points = max(0, points - earned_point)`. | 🟢 |
| not-awarded | reversed | `destroy` of a draft | No points to reverse (guarded by `points_awarded_at`). | 🟢 |

> 🟡 Reopening a `done` order to `draft` does **not** clear `points_awarded_at`; the guard intentionally keeps the award in place so re-finalising never double-credits. Points are only reversed on deletion, not on reopen.

```mermaid
stateDiagram-v2
    [*] --> not_awarded: order created
    not_awarded --> awarded: status -> done\npoints += earned_point\nstamp points_awarded_at
    awarded --> awarded: re-save while done\n(idempotent, no re-award)
    awarded --> reversed: destroy\npoints = max(0, points - earned_point)
    not_awarded --> reversed: destroy draft\n(nothing to reverse)
    reversed --> [*]
```

---

## 3. Customer debt (accounts-receivable) balance

`customers.debt_total` is a running balance mutated only through the append-only `customer_debts` ledger. There is no per-entry status; the "state" is the balance, driven by entry types. `CustomerDebt::record` locks the customer row for every mutation. 🟢

### Balance states

- **no-debt** — `debt_total = 0`. Customer does not appear on `/debts`.
- **in-debt** — `debt_total > 0`. Customer appears on `/debts` (ordered by balance desc).

### Ledger entry effects

| Entry type | Effect on `debt_total` | Trigger | Guard | Confidence |
|------------|------------------------|---------|-------|------------|
| `pos_debt` | increase | `done` order with `debt_amount>0` | Requires customer; `debt_amount ≤ total`; posts once (`debt_locked`). | 🟢 |
| `manual_debt` | increase | `CustomerController::storeDebt` | Staff-entered amount. | 🟢 |
| `repayment` | decrease | `CustomerController::storeRepayment` | Rejected if amount > current `debt_total`. | 🟢 |
| `debt_void` | decrease (reversal) | order `destroy` | Clamped by current balance; shortfall noted. | 🟢 |

Every entry writes `balance_after`, giving a full audit trail even after the originating order is hard-deleted (`order_id` set null). 🟢

```mermaid
stateDiagram-v2
    [*] --> no_debt
    no_debt --> in_debt: pos_debt / manual_debt\ndebt_total += amount
    in_debt --> in_debt: pos_debt / manual_debt (more debt)\nor partial repayment / partial void
    in_debt --> no_debt: repayment / debt_void\nbrings debt_total to 0
    note right of in_debt
        record() locks customer row;
        repayment rejected if > debt_total;
        debt_void clamped by balance
    end note
```

---

## 4. Product catalogue lifecycle

`products` uses SoftDeletes. 🟢

### States

- **active** — visible in catalogue, POS lookups, dropdowns.
- **soft-deleted** — `deleted_at` set; row renamed `'(DELETED) ' + name` by the model `deleted` hook; excluded from listings and POS. History (`order_product`) still references it.

```mermaid
stateDiagram-v2
    [*] --> active: create
    active --> active: update
    active --> soft_deleted: delete\nset deleted_at + rename "(DELETED) name"
    soft_deleted --> active: restore (Eloquent)\n[🟡 no explicit UI path found]
```

> 🟡 SoftDeletes technically permits `restore()`, but no UI/route exercising it was found; treat restore as a latent capability, not a business flow.

---

## 5. Gift availability

Gifts are reference data with an active flag and a derived stock state rather than an explicit lifecycle enum. 🟢

### States

- **active + in-stock** — `active=1` AND `quantity_available (quantity - used) !== 0`. Redeemable (subject to per-customer `limit` and point cost).
- **active + out-of-stock** — `active=1` AND `quantity_available === 0`.
- **inactive** — `active=0`; excluded by `scopeActive`.
- **soft-deleted** — `deleted_at` set (SoftDeletes).

Transitions are staff edits (toggle `active`, adjust `quantity`) plus redemption incrementing `used` (which can move a gift into out-of-stock). The `quantity` field cannot be edited below `used` (dynamic `min:{used}` rule). 🟢

> 🟡 GAP: `quantity_available` uses a strict `=== 0` out-of-stock test, so a hypothetical `used > quantity` (negative available) would read as *in-stock*. Made hard to reach by the `min:used` rule and the transactional redemption (see ADR-0007).

```mermaid
stateDiagram-v2
    [*] --> active_in_stock: create (active=1)
    active_in_stock --> active_out_of_stock: used reaches quantity
    active_out_of_stock --> active_in_stock: staff raises quantity
    active_in_stock --> inactive: active=0
    active_out_of_stock --> inactive: active=0
    inactive --> active_in_stock: active=1
    active_in_stock --> [*]: soft delete
    inactive --> [*]: soft delete
```
