# Debts — Technical Design

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

## Interface

Single HTTP endpoint, admin group (`['web','admin']`, empty admin prefix).

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/debts` | `q?: string` (query), `page?: int` (query) | `text/html` — `pages.debts` | 200, 302 (unauthenticated → `auth/login`) |

Controller symbol:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `DebtController::index` | `(Request $request)` | view `pages.debts` | Read-only; sets `$this->header`/`$this->breadcrumb`, builds `$customers`, renders. 🟢 (`:10-29`) |

View-supplied variable:

| View variable | Type | Meaning |
|---------------|------|---------|
| `customers` | `LengthAwarePaginator<Customer>` | Customers with `debt_total > 0`, `debt_total` desc, 30/page, optionally `q`-filtered. Each `Customer` exposes `fullname`, `phone`, `debt_total` (float), `id`. 🟢 (`:17-28`) |

## Main Flow

1. `GET /debts` reaches `DebtController::index` through the admin route group. 🟢 (`routes/web.php:67`)
2. Set `$this->header = __('Danh sách công nợ')` and a one-item breadcrumb `[['text' => $this->header]]`. 🟢 (`:12-15`)
3. Build the base query `Customer::where('debt_total','>',0)->orderBy('debt_total','desc')` (a query builder, not yet executed). 🟢 (`:17-18`)
4. If `$q = $request->get('q')` is truthy, chain a **grouped** closure: `->where(fn($query) => $query->where('phone','like',"%$q%")->orWhere('fullname','like','%'.$q.'%'))`. The grouping keeps the OR ANDed with `debt_total > 0`. 🟢 (`:20-24`)
5. Execute `->paginate(30)` into `$customers`. 🟢 (`:26`)
6. Return `$this->view('pages.debts', compact('customers'))`. 🟢 (`:28`)
7. The view renders a table (fullname / phone / `number_format(debt_total,1) ₫`), each row clickable to `route('customers.debt', [$customer->id])`, plus two modals whose select2 pickers query `/customers/scan` and whose forms POST to `/customers/{id}/repayments` and `/customers/{id}/debts`. 🟢 (`resources/views/pages/debts.blade.php`)

## Alternative Flows

- **No `q` provided:** `$request->get('q')` is falsy → no extra predicate; the full debtor list is paginated as-is. 🟢 (`:20`)
- **No debtors / empty page:** the query returns an empty paginator; the blade `@forelse … @empty` renders a single "Không có dữ liệu" ("No data") row. No error. 🟢 (`debts.blade.php`)
- **Out-of-range `page`:** Laravel's paginator returns an empty result set for a page beyond the last; the empty-state row renders. 🟡 (standard paginator behaviour)
- **Unauthenticated:** the admin group middleware redirects to `auth/login` before the action runs (`302`). 🟢 (`routes/web.php:23-27`)

## Dependencies

- **`Customer` model** — source of the debtor list; provides the `debt_total` float column (maintained by `CustomerDebt::record`) and the implicit `SoftDeletes` scope. 🟢 (`app/Models/Customer.php:6,12,15-18`)
- **`customers-scan` (`GET /customers/scan`)** — the select2 pickers in both modals fetch candidate customers as the operator types (min 3 chars). Consumed contract; owned elsewhere. 🟢 (`debts.blade.php`)
- **`customers-debt-actions`** — owns the write targets the view points at: `POST /customers/{id}/repayments` (repayment), `POST /customers/{id}/debts` (manual debt), and the row link `GET /customers/{id}/debt` (ledger page). This unit never invokes them server-side; the browser does. 🟢 (`debts.blade.php`; `routes/web.php:58,60,61`)
- **`pages.debts` Blade view + admin layout (`admin::index`)** — presentation, CSRF (`csrf_field`/`Form::open`), pagination links (`has_paging`, `$customers->appends(...)->links()`). 🟢 (`debts.blade.php`)

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| A dedicated read-only receivables screen distinct from per-customer debt pages | `DebtController` has only `index`; all writes live in `CustomerController` | 🟢 (`DebtController.php:10-29`) |
| Denormalised live balance drives the list (`customers.debt_total`), not the nightly summary | `where('debt_total','>',0)` reads the column `CustomerDebt::record` maintains | 🟢 (`:17`; cross-ref `customers-debt-actions`) |
| Grouped search closure to preserve the `debt_total > 0` scope under OR | `->where(function($query) use ($q){ … orWhere … })` | 🟢 (`:20-24`) |
| Bounded output via `paginate(30)` (fixed from an earlier unbounded `->get()`) | `$customers = $list->paginate(30)` | 🟢 (`:26`, `flowcharts/debts.md`) |
| Writes delegated to the browser (modals POST to `customers` routes) rather than a controller action | modal `action` attributes set client-side to `/customers/{id}/…` | 🟢 (`debts.blade.php`) |

## Internal State

None. `DebtController::index` holds no state beyond the request-scoped `$header`/`$breadcrumb`/`$customers` locals; it reads `customers` and writes nothing. The only persistent state it reflects — `customers.debt_total` — is owned and mutated by `customers-debt-actions` (`CustomerDebt::record`). 🟢 (`DebtController.php:10-29`)

## Observability

None. The action emits no log, metric, or trace; a slow or empty debtor list produces no signal. 🔴 (`DebtController.php:10-29`, absence)

## Risks and Gaps

- 🟡 **Un-indexable search.** `phone LIKE '%q%'` / `fullname LIKE '%q%'` use a leading wildcard, so no index applies; the search does a full scan and degrades as the customer table grows.
- 🟡 **LIKE metacharacter leak.** A user-typed `%` or `_` in `q` is interpreted as a LIKE wildcard (not escaped). Values are parameter-bound, so there is no SQL injection — only surprising match breadth. (`:22`)
- 🟡 **Picker not scoped to debtors.** The modal select2 sources any live customer from `/customers/scan`; a repayment started for a non-debtor is rejected downstream by `storeRepayment`'s over-balance throw, but the UI offers no hint before submission. (`debts.blade.php`; `customers-debt-actions`)
- 🟡 **No per-record authorization.** Any authenticated admin sees every customer's balance and can act on it — authentication-only access control. (ADR-0009)
- 🔴 **No observability** on a screen that surfaces money owed (see above).
- 🟢 **Search grouping is correct** (not a gap) — unlike the ungrouped `orWhere` defects in `customers-crud` and `customers-scan`, this closure keeps the OR ANDed with `debt_total > 0`.
