# Customers Debt Actions Design

## Data Model

### `customer_debts` table

| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | bigint PK | — | — |
| `customer_id` | unsignedBigInt FK | ✗ | → `customers.id` ON DELETE CASCADE |
| `order_id` | unsignedBigInt FK | ✓ | → `orders.id` ON DELETE SET NULL — nulled when the source order is hard-deleted |
| `related_debt_id` | unsignedBigInt FK | ✓ | self-referencing → `customer_debts.id` ON DELETE SET NULL — links a `debt_void` back to its `pos_debt` |
| `type` | enum | ✗ | `pos_debt \| manual_debt \| repayment \| debt_void` |
| `amount` | decimal(15,1) | ✗ | Sub-0.1 inputs round to one decimal place on storage |
| `balance_after` | decimal(15,1) | ✓ | Running-balance snapshot at the moment of insert; audit trail survives order deletion |
| `note` | string | ✓ | Free-text reason supplied by the admin |
| `created_by` | unsignedInteger | ✓ | Admin id at write time; no FK constraint to `admin_users` |
| `created_at` / `updated_at` | timestamps | — | — |

### `customers` table (relevant column)

| Column | Type | Notes |
|--------|------|-------|
| `debt_total` | decimal | Denormalised live balance; cast to `float` on the `Customer` model; mutated in lockstep with every `customer_debts` insert by `CustomerDebt::record` |

### ERD (simplified)

```mermaid
erDiagram
    customers {
        bigint id PK
        decimal debt_total
    }
    customer_debts {
        bigint id PK
        bigint customer_id FK
        bigint order_id FK
        bigint related_debt_id FK
        enum type
        decimal amount
        decimal balance_after
        string note
        uint created_by
    }
    orders {
        bigint id PK
    }
    customers ||--o{ customer_debts : "hasMany debts()"
    orders ||--o{ customer_debts : "belongsTo order()"
    customer_debts ||--o| customer_debts : "related_debt_id"
```

### Type ownership per producer

| Entry type | Written by |
|------------|------------|
| `pos_debt` | `orders-crud` (on a `done` order with `debt_amount > 0`) |
| `manual_debt` | **this unit** (`CustomerController::storeDebt`) |
| `repayment` | **this unit** (`CustomerController::storeRepayment`) |
| `debt_void` | `orders-crud` (`CustomerDebt::voidForOrder` on order `destroy`) |

All four types share the same table and are all visible on the ledger page.

---

## Internal Flows

### `CustomerDebt::record` — shared balance primitive (`CustomerDebt.php:38-64`)

This is the single mutation point for `customers.debt_total`. Both write controllers call it; so do the two `orders-crud` entry points.

```mermaid
flowchart TD
    A[Enter record] --> B[DB::transaction open]
    B --> C["Customer::lockForUpdate()->find(customer_id)"]
    C --> D{type == repayment?}
    D -- yes --> E{amount > locked.debt_total?}
    E -- yes --> F["throw Exception: over-balance message"]
    F --> G[Transaction rolls back — no row written]
    E -- no --> H[locked.debt_total -= amount]
    D -- no --> I["locked.debt_total += amount\n(pos_debt / manual_debt / debt_void)"]
    H --> J["CustomerDebt::create({customer_id, order_id, type, amount, balance_after: locked.debt_total, note, created_by})"]
    I --> J
    J --> K[locked.save()]
    K --> L[Transaction commits — returns void]
```

Concurrency guarantee: the `lockForUpdate` on the customer row serialises all concurrent writers for the same customer. Two simultaneous repayments cannot both pass the balance check.

---

### `GET /customers/{customer}/debt` — ledger page (`CustomerController.php:263-280`)

```mermaid
sequenceDiagram
    participant Browser
    participant Controller as CustomerController::debt
    participant DB

    Browser->>Controller: GET /customers/{id}/debt [?page=N]
    Controller->>DB: Customer::findOrFail(id)
    DB-->>Controller: Customer (or ModelNotFoundException → 404)
    Controller->>Controller: debtTotal = item.debt_total ?? 0
    Controller->>DB: CustomerDebt::with('order')\n  .where('customer_id', id)\n  .orderBy('id','desc')\n  .paginate(20)
    DB-->>Controller: LengthAwarePaginator<CustomerDebt>
    Controller->>Browser: 200 HTML — pages.customer-debt\n(item, debtTotal, debtOrders)
```

The balance read (`debt_total`) is the live denormalised value, not aggregated from ledger rows at query time.

---

### `POST /customers/{customer}/debts` — manual debt (`CustomerController.php:379-399`)

```mermaid
sequenceDiagram
    participant Browser
    participant Controller as CustomerController::storeDebt
    participant Record as CustomerDebt::record

    Browser->>Controller: POST /customers/{id}/debts\n{amount, note}
    Controller->>Controller: validate(amount:required|numeric|min:0.01, note:nullable|string)
    alt validation fails
        Controller-->>Browser: 302 back + withErrors
    end
    Controller->>Controller: Customer::findOrFail(id) → 404 on miss
    Controller->>Record: record(customer, 'manual_debt', (float)amount, null, note, Admin::user()?->id)
    Note right of Record: No try/catch — manual_debt never throws
    Record-->>Controller: void
    Controller-->>Browser: 302 back + admin_toastr('Ghi nợ thành công')
```

---

### `POST /customers/{customer}/repayments` — repayment (`CustomerController.php:401-425`)

```mermaid
sequenceDiagram
    participant Browser
    participant Controller as CustomerController::storeRepayment
    participant Record as CustomerDebt::record

    Browser->>Controller: POST /customers/{id}/repayments\n{amount, note}
    Controller->>Controller: validate(amount:required|numeric|min:0.01, note:nullable|string)
    alt validation fails
        Controller-->>Browser: 302 back + withErrors
    end
    Controller->>Controller: Customer::findOrFail(id) → 404 on miss
    Controller->>Record: record(customer, 'repayment', (float)amount, null, note, Admin::user()?->id)
    alt amount > debt_total (over-balance)
        Record-->>Controller: throws Exception (message includes current balance in ₫)
        Controller-->>Browser: 302 back + withErrors(e.getMessage())
    else success
        Record-->>Controller: void
        Controller-->>Browser: 302 back + admin_toastr('Thu nợ thành công')
    end
```

---

### Route registration order (`routes/web.php:58-62`)

The three unit routes are declared **before** `resource('/customers', 'CustomerController')`. This is required: the resource macro would otherwise register a `GET /customers/{customer}` `show` and `PUT /customers/{customer}` `update` that could shadow or conflict with the `/{customer}/debt`, `/{customer}/debts`, and `/{customer}/repayments` paths. Declaration order is load-bearing.

---

## Technical Decisions

| Decision | Rationale / Evidence |
|----------|----------------------|
| Single locked `record` primitive for all balance mutations | Both write endpoints and both `orders-crud` entry points funnel through one method, making the balance invariant enforceable in one place. `CustomerDebt.php:38-64`; `CustomerController.php:388,411` |
| `debt_total` denormalised on `customers`, mutated synchronously | Avoids a `SUM(amount)` aggregation query on every balance read; the ledger page and the `/debts` overview both need the current balance. `CustomerDebt.php:47-62`, `Customer.php:15-19` |
| Append-only ledger with per-entry `balance_after` snapshot | Provides a full audit trail independent of order lifecycle; once an order is hard-deleted, its `pos_debt` rows remain in the ledger with the historical balance. `migration:18`, `CustomerDebt.php:57` |
| Pessimistic row lock (`lockForUpdate`) inside a DB transaction | Serialises concurrent writers on the same customer; two simultaneous repayments cannot both pass the `> debt_total` check. `CustomerDebt.php:40-41` |
| `storeDebt` has no try/catch; `storeRepayment` does | `manual_debt` follows the add path in `record` and never throws; only the subtract path can fail the balance invariant. Asymmetric guarding matches the asymmetric risk. `CustomerController.php:388-398` vs `:410-421` |
| `created_by` stored as a plain `unsignedInteger`, no FK | Loose provenance; a deleted admin leaves a dangling id. Accepted under ADR-0009 (single-admin deployment model). `migration:20` |
| No per-record authorization scope | Any authenticated admin can add debt to or collect from any customer. Authentication-only model per ADR-0009. |

---

## Notes

### Constraints and pitfalls

- **Amount floor vs storage precision.** Validation accepts `amount >= 0.01`, but storage is `decimal(15,1)`. An input of `0.04` is accepted and rounded to `0.0` on write — a legal request that records a zero-amount entry with no balance change. If ₫ amounts are always whole numbers, tighten the validation rule to `min:1` or `integer`.
- **Manual debt has no upper bound.** Only `repayment` and `debt_void` can reverse an inflated balance. A mistyped large `manual_debt` amount has no confirmation step.
- **`order_id` set-null on order delete.** The eager-loaded `with('order')` relation yields `null` for `pos_debt` rows whose orders have been hard-deleted. The view must handle a null `order` relationship (falls back to the entry note or a "deleted order" label).
- **No observability.** None of the three routes emit logs, metrics, or traces. The only runtime artifact of a money-mutating operation is the persisted row (`balance_after`, `created_by`). Rejected repayments and transaction failures are invisible outside the database and the flash redirect.
- **`debt_total` must not be recomputed independently.** Because `balance_after` is a snapshot at insert time (not `SUM` of amounts), any re-aggregation that rounds differently from the sequential `+= / -=` arithmetic in `record` will diverge from the stored trail. Migrations must preserve both columns exactly.
