# Debts Design

## Data Model

The unit owns no tables. It is a read-only projection over two existing structures.

**`customers` table (owned by `customers-crud`)**

| Column | Type | Role in this unit |
|--------|------|-------------------|
| `id` | PK | Row link target (`route('customers.debt', [id])`) |
| `fullname` | string | Displayed in table; searched via `LIKE '%q%'` |
| `phone` | string | Displayed in table; searched via `LIKE '%q%'` |
| `debt_total` | decimal(15,1) cast as float | Filter predicate (`> 0`), sort key (desc), display value |
| `deleted_at` | timestamp\|null | `SoftDeletes` scope — deleted customers are automatically excluded |

`Customer` model: `app/Models/Customer.php:6,12,15-18`

**`customer_debts` ledger (owned by `customers-debt-actions`)**

Not queried directly by this unit. `customers.debt_total` is a denormalised live balance maintained by `CustomerDebt::record`; this unit reads the summary column, never the ledger rows.

**Paginator output**

The query produces a `LengthAwarePaginator<Customer>` (30 per page). No additional model or DTO is introduced.

---

## Internal Flows

### Primary flow — authenticated request

```mermaid
sequenceDiagram
    participant Browser
    participant Middleware as admin group middleware
    participant Controller as DebtController::index
    participant DB as customers table
    participant View as pages.debts

    Browser->>Middleware: GET /debts[?q=…&page=…]
    Middleware-->>Browser: 302 auth/login (if unauthenticated)
    Middleware->>Controller: authenticated request
    Controller->>Controller: set $header, $breadcrumb
    Controller->>DB: Customer::where('debt_total','>',0)\n  ->orderBy('debt_total','desc')
    alt q present
        Controller->>DB: ->where(fn => phone LIKE %q%\n  OR fullname LIKE %q%)
    end
    Controller->>DB: ->paginate(30)
    DB-->>Controller: LengthAwarePaginator<Customer>
    Controller->>View: view('pages.debts', compact('customers'))
    View-->>Browser: 200 text/html
```

### Query construction detail

```
$list = Customer::where('debt_total', '>', 0)
                ->orderBy('debt_total', 'desc');   // DebtController.php:17-18

if ($q = $request->get('q')) {
    $list->where(function ($query) use ($q) {      // DebtController.php:20-24
        $query->where('phone', 'like', "%$q%")
              ->orWhere('fullname', 'like', '%' . $q . '%');
    });
}

$customers = $list->paginate(30);                  // DebtController.php:26
```

The closure is **grouped**: the OR between `phone` and `fullname` is ANDed with `debt_total > 0` as a unit. This is intentional — see Technical Decisions.

### Alternative flows

| Condition | Outcome |
|-----------|---------|
| `q` absent | Base query runs without the closure; full debtor list paginated |
| No debtors exist | Paginator returns empty collection; Blade `@forelse … @empty` renders "Không có dữ liệu" row |
| `page` beyond last page | Laravel paginator returns empty result; same empty-state path |
| Unauthenticated request | Admin group middleware returns `302 → auth/login` before action runs |

### Write delegation

`DebtController::index` performs **no writes**. The view renders two modals whose `<form action>` attributes are set client-side (via select2 picker) to:

- `POST /customers/{id}/repayments` — repayment (owned by `customers-debt-actions`)
- `POST /customers/{id}/debts` — manual debt entry (owned by `customers-debt-actions`)

Each form carries `csrf_field()`. The controller is never invoked for these submissions.

---

## Technical Decisions

| Decision | Rationale | Evidence |
|----------|-----------|----------|
| Read-only controller (`index` only) | The receivables overview is a projection; all mutations belong to `customers-debt-actions` which owns the ledger write path | `DebtController.php:10-29`; `routes/web.php:67` |
| Live balance from `customers.debt_total`, not the nightly summary | The field is maintained in lockstep by `CustomerDebt::record`, so figures are always current — unlike the nightly `customer_order_summary` read model behind `customers-statistics` | `Customer.php:15-18`; cross-ref `customers-debt-actions` |
| Grouped `where` closure for the `q` filter | Prevents the OR from leaking past the `debt_total > 0` predicate (the ungrouped `orWhere` anti-pattern present in `customers-crud` and `customers-scan` causes exactly this bug) | `DebtController.php:20-24` |
| Fixed page size of 30 via `paginate(30)` | Bounds the query result; the prior implementation used an unbounded `->get()` | `DebtController.php:26` |
| Write actions delegated to the browser | Modals POST directly to `customers` routes; no server-side proxy method in `DebtController` | `debts.blade.php`; `routes/web.php:58,60,61` |

---

## Notes

**Constraints and pitfalls**

- **Leading-wildcard LIKE — no index.** Both `phone LIKE '%q%'` and `fullname LIKE '%q%'` use a leading wildcard. No B-tree index can satisfy these predicates; the database performs a full table scan. Degrades as the `customers` table grows. (`DebtController.php:22`)

- **LIKE metacharacter leak.** A `%` or `_` character typed into `q` is interpreted as a SQL `LIKE` wildcard, widening the match beyond what the user intended. Values are parameter-bound (no SQL injection risk), but the behaviour is surprising. (`DebtController.php:22`)

- **Customer picker is not scoped to debtors.** The select2 pickers in both modals query `GET /customers/scan` (any live customer, min 3 chars). Starting a repayment for a customer with `debt_total = 0` succeeds in the UI but is rejected downstream by `CustomerController::storeRepayment` (over-balance throw) with no prior hint in this screen. (`debts.blade.php`; `customers-debt-actions`)

- **Authentication only — no per-record scope.** Any authenticated admin can view every customer's balance and initiate actions against any of them. Per-record or per-branch authorization is absent (ADR-0009).

- **No observability.** `DebtController::index` emits no log entry, metric, or trace. A slow query, an empty receivables list, or a missing select2 response produces no operational signal. (`DebtController.php:10-29`, absence)
