# Debts — Requirements

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

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

## Overview

`debts` is the back-office accounts-receivable overview screen (`GET /debts`, `DebtController::index`). It lists every customer who currently owes money — those with `debt_total > 0` — ordered by outstanding balance descending, with an optional name/phone search. It is a read-only HTML page: the controller performs **no writes**. The two action buttons it renders (Thu nợ / record repayment, Ghi nợ tay / record manual debt) submit to the `customers` A/R endpoints owned by `customers-debt-actions`, and each row links to that customer's debt ledger. 🟢 (`routes/web.php:67`, `DebtController.php:10-29`)

## Responsibilities

- Set the page header to `Danh sách công nợ` ("Accounts-receivable list") and a single-item breadcrumb. 🟢 (`:12-15`)
- Build the debtor list: `Customer::where('debt_total','>',0)->orderBy('debt_total','desc')` — only customers with a positive live balance, largest debt first. 🟢 (`:17-18`)
- Apply an optional search: when `q` is present, restrict to customers whose `phone LIKE %q%` **or** `fullname LIKE %q%`, wrapped in a grouped closure so the OR stays AND-scoped to `debt_total > 0`. 🟢 (`:20-24`)
- Paginate the result 30 per page. 🟢 (`:26`)
- Render `pages.debts` with the paginated `customers` collection. 🟢 (`:28`)
- Present (in the view, not the controller) two customer-picker modals and per-row links that delegate all state changes to the `customers-debt-actions` unit. 🟢 (`resources/views/pages/debts.blade.php`)

## Business Rules

- **Only positive-balance customers are listed.** The base query filters `debt_total > 0`; a customer with a zero or negative balance never appears. 🟢 (`:17`)
- **Ordered by size of debt.** Rows are sorted `debt_total` descending, so the largest debtor is first. 🟢 (`:18`)
- **Live balance, not a nightly summary.** The screen reads `customers.debt_total` directly (cast `float`), which `CustomerDebt::record` maintains in lockstep with the ledger — so figures are current, unlike the nightly `customer_order_summary` read model behind `customers-statistics`. 🟢 (`Customer.php:15-18`, `data-dictionary.md:161`; cross-ref `customers-debt-actions`)
- **Search is grouped — no OR leak.** The `q` filter is a grouped closure `where(fn => phone LIKE OR fullname LIKE)`, so the `debt_total > 0` predicate remains ANDed against the whole OR group. This is the *correct* pattern; contrast the ungrouped `orWhere` bugs flagged in `customers-crud` (month filter) and `customers-scan` (soft-delete scope). 🟢 (`:20-24`)
- **Soft-deleted customers excluded automatically.** `Customer` uses `SoftDeletes`, so the default scope adds `deleted_at IS NULL`; a soft-deleted debtor never shows. 🟢 (`Customer.php:6,12`)
- **Read-only projection.** `DebtController::index` never writes. All mutations happen in `customers-debt-actions` via the modals' POST targets (`/customers/{id}/repayments`, `/customers/{id}/debts`) and the row link (`/customers/{id}/debt`). 🟢 (`DebtController.php:10-29`; `debts.blade.php`)
- **The customer picker is not restricted to debtors.** The modals fetch candidates from `GET /customers/scan` (any live customer, min 3 chars), so a repayment can be started for a customer who owes nothing — in which case `CustomerController::storeRepayment` rejects it (over-balance throw). Manual-debt entry for any customer is valid. 🟡 (`debts.blade.php` select2 → `/customers/scan`; `customers-debt-actions`)
- **Money display.** Balances render as `number_format(debt_total, 1) ₫` (one decimal place, matching `decimal(15,1)` storage). 🟢 (`debts.blade.php`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | List customers with `debt_total > 0`, balance descending | Must | Given customers with balances 0, 50, 120 → only the 50 and 120 rows show, 120 first. 🟢 |
| RF-02 | Paginate the debtor list 30 per page | Must | With 35 debtors, page 1 shows 30 rows and page 2 shows 5. 🟢 |
| RF-03 | Optional `q` search over phone OR fullname, scoped to debtors | Should | `?q=090` lists only debtors whose phone or name contains `090`; a zero-balance match is still excluded. 🟢 |
| RF-04 | Render each debtor as a row linking to their debt ledger | Should | Clicking a row navigates to `GET /customers/{id}/debt`. 🟢 (`debts.blade.php`) |
| RF-05 | Offer repayment / manual-debt entry points that delegate to `customers-debt-actions` | Should | The Thu nợ / Ghi nợ tay modals POST to `/customers/{id}/repayments` and `/customers/{id}/debts`; this controller writes nothing. 🟢 |
| RF-06 | Require an authenticated admin session | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:23-27`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:23-27,67` | 🟢 |
| Performance | Debtor list bounded to 30 rows per page via `paginate(30)` (previously an unbounded `->get()`) | `DebtController.php:26` | 🟢 |
| Performance | Leading-wildcard `LIKE '%q%'` on `phone`/`fullname` cannot use a column index → full scan that degrades as the customer base grows | `DebtController.php:22` | 🟡 |
| Observability | None — the action emits no log, metric, or trace | `DebtController.php:10-29` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Given an authenticated administrator and customers with debt balances 0, 50, and 120
When he accesses GET /debts
Then he receives HTTP 200 with the debts page listing only the customers with balance 50 and 120, the 120 one first, 30 per page

Given an authenticated administrator
When he accesses GET /debts?q=090
Then the list shows only debtor customers whose phone or fullname contains "090", remaining restricted to debt_total > 0

Given no customer has debt_total > 0
When GET /debts is called
Then the page renders with the empty table ("Không có dữ liệu"), with no error

Given a request with no authenticated admin session
When GET /debts is called
Then it receives HTTP 302 redirecting to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| List positive-balance customers, balance desc (RF-01) | Must | The entire purpose of the screen |
| Paginate 30/page (RF-02) | Must | Bounds the query; the prior unbounded `->get()` was the fix driver |
| Delegate repayment/manual-debt writes to `customers-debt-actions` (RF-05) | Must | The screen's operational value is the entry point to collect/record debt |
| `q` search (RF-03) | Should | Convenience narrowing; the page works fully without it |
| Per-row link to the ledger (RF-04) | Should | Navigation aid; each customer's debt page is reachable elsewhere too |
| Admin authentication (RF-06) | Must | Enforced by the route group for the whole back office |

> Priority inferred from the action's role as a read-only receivables dashboard and its position in the debt workflow.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/DebtController.php:10-29` | `DebtController::index` | 🟢 |
| `routes/web.php:67` | `GET /debts` route (`debts.index`) | 🟢 |
| `app/Models/Customer.php:6,12,15-18,39-42,58-61` | `Customer` (SoftDeletes, `debt_total` float cast, `debts()` hasMany, `scopeCode`) | 🟢 |
| `resources/views/pages/debts.blade.php` | Debtor table + repayment / manual-debt modals + select2 picker | 🟢 |
| `data-dictionary.md:161,167-178,256` | `customers.debt_total`, `customer_debts` ledger, read-only projection note | 🟢 |
