# Customers Debt Actions — Requirements

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

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

## Overview

`customers-debt-actions` is the per-customer **accounts-receivable (A/R)** surface: one read-only ledger page plus the two write endpoints that mutate a customer's debt balance. It groups `GET /customers/{customer}/debt` (`CustomerController::debt`, the "Công nợ" tab showing the live balance and the full `customer_debts` history), `POST /customers/{customer}/debts` (`CustomerController::storeDebt`, record a **manual debt**), and `POST /customers/{customer}/repayments` (`CustomerController::storeRepayment`, record a **repayment**). Both writes go through the single locked, append-only `CustomerDebt::record` primitive that adjusts `customers.debt_total` and writes a `balance_after` snapshot. 🟢 (`routes/web.php:58,60,61`, `CustomerController.php:263-280,379-425`, `CustomerDebt.php:38-64`)

## Responsibilities

- **Debt ledger page** — resolve the customer by route id via `findOrFail` (`404` otherwise), expose the **live** `debt_total`, and paginate the customer's `customer_debts` entries (newest first, 20/page) with their originating order eager-loaded. 🟢 (`:263-280`)
- **Manual debt** — validate an `amount` (numeric, ≥ 0.01) and optional `note`, resolve the customer (`404`), and append a `manual_debt` ledger entry that **increases** `debt_total`, tagging the acting admin as `created_by`. 🟢 (`:379-399`)
- **Repayment** — validate the same fields, resolve the customer (`404`), and append a `repayment` ledger entry that **decreases** `debt_total`, rejecting the write (with a message) if the amount exceeds the current balance. 🟢 (`:401-425`)
- Delegate every balance mutation to `CustomerDebt::record`, which runs inside a DB transaction with a `lockForUpdate` on the customer row to serialise concurrent writes and always records `balance_after`. 🟢 (`CustomerDebt.php:38-64`)
- Provide the modals/entry points that the `debts` overview page and the customer-detail tabs post to; this unit is where `manual_debt` and `repayment` ledger rows originate. 🟢 (`customer-debt.blade.php`, `debts` unit)

## Business Rules

- **The ledger is append-only with a locked running balance.** `CustomerDebt::record` locks the customer row (`Customer::lockForUpdate()->find`), adjusts `debt_total` (`+amount` for `manual_debt`, `−amount` for `repayment`), inserts one row capturing `balance_after`, then saves the customer — all in one transaction. Nothing edits or deletes prior entries. 🟢 (`CustomerDebt.php:38-64`; `domain.md#10`, `state-machines.md#3`)
- **A repayment cannot exceed the current balance.** Under the lock, `record` throws if `amount > debt_total` (`"Số tiền thu nợ vượt quá số dư nợ hiện tại (<balance> ₫)"`); `storeRepayment` catches it and redirects back with that error. Manual debt has no upper bound. 🟢 (`CustomerDebt.php:43-47`, `CustomerController.php:410-421`)
- **Amount floor is 0.01.** Both writes validate `amount => required|numeric|min:0.01`, so zero and negative amounts are rejected before `record`. 🟢 (`:381-384,403-406`)
- **`debt_total` is the live balance, not a snapshot.** The ledger page reads `customer.debt_total` directly (float cast), so it is always current — unlike `customers-statistics`, which reads the nightly `customer_order_summary`. 🟢 (`:272`, `Customer.php:15-19`)
- **Four entry types share one ledger.** `type ∈ {pos_debt, manual_debt, repayment, debt_void}`. This unit writes `manual_debt` and `repayment`; `pos_debt` is posted by `orders-crud` when a `done` order carries `debt_amount>0`, and `debt_void` is written by `orders-crud`'s `destroy` (`CustomerDebt::voidForOrder`) — all four land in the same `customer_debts` table read by the ledger page. 🟢 (`migration:16`, `CustomerDebt.php:83-144`, `domain.md#37-40`)
- **Full audit survives order deletion.** Every entry stores `balance_after`; the `order_id` FK is `onDelete: set null`, so hard-deleting a debt-bearing order nulls the link but the historical entry (and a paired `debt_void`) remain. The ledger view falls back to the entry note / "Đơn hàng đã bị xóa" when `order` is null. 🟢 (`migration:24`, `customer-debt.blade.php`, `domain.md#103` GAP-O1 confirmed as designed)
- **The acting admin is captured as `created_by`.** Both writes pass `Admin::user() ? Admin::user()->id : null` for provenance; the column is a plain `unsignedInteger` with no FK to `admin_users`. 🟡 (`:394,417`, `migration:20`)
- **Manual debt is not wrapped in try/catch — intentionally.** `storeDebt` calls `record` bare because `manual_debt` only *adds* and `record` never throws for it; only `repayment` (which can exceed the balance) is guarded. 🟢 (`:388-398` vs `:410-421`)
- **Balance zero exits `/debts`.** A repayment bringing `debt_total` to `0` moves the customer off the `/debts` overview (`debt_total > 0` filter, `debts` unit). 🟢 (`state-machines.md#3`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | Render the debt ledger page for a customer resolved by route id | Must | `GET /customers/{id}/debt` for a valid id → HTTP `200` with the live `debt_total` and the paginated ledger. 🟢 |
| RF-02 | Resolve the customer or return `404` (soft-delete aware) on all three routes | Must | An unknown/soft-deleted id on `debt`, `debts`, or `repayments` → HTTP `404`. 🟢 |
| RF-03 | Show the customer's `customer_debts` entries, newest first, 20/page, with the originating order eager-loaded | Must | A customer with 25 entries shows 20 on page 1, 5 on page 2, `id` desc; `pos_debt` rows show the order code without an extra query. 🟢 |
| RF-04 | Record a `manual_debt` that increases `debt_total` | Must | `POST .../debts` with `amount=100` on a customer at `50` → new balance `150`, one `manual_debt` row with `balance_after=150`. 🟢 |
| RF-05 | Record a `repayment` that decreases `debt_total` | Must | `POST .../repayments` with `amount=30` on a customer at `150` → balance `120`, one `repayment` row with `balance_after=120`. 🟢 |
| RF-06 | Reject a repayment exceeding the current balance | Must | `POST .../repayments` with `amount > debt_total` → redirect back with the over-balance error, no ledger row, balance unchanged. 🟢 |
| RF-07 | Validate `amount` (numeric, ≥ 0.01) and optional `note` on both writes | Must | `amount=0`, negative, or non-numeric → validation error, no write. 🟢 |
| RF-08 | Serialise concurrent balance mutations with a customer row lock | Must | Two simultaneous repayments cannot both pass the balance check and overdraw; `record` runs under `lockForUpdate` in a transaction. 🟢 |
| RF-09 | Capture the acting admin as `created_by` on each write | Should | A logged-in admin's id is stored on the new entry; `null` if no admin resolved. 🟡 |
| RF-10 | Require an authenticated admin session on all three routes | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:23-28`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Consistency | Balance mutation + ledger insert are atomic and serialised via a DB transaction + `lockForUpdate` on the customer | `CustomerDebt.php:40-63` | 🟢 |
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:23-28,58-61` | 🟢 |
| Security | CSRF protection on both POSTs via Laravel `Form::open` token | `customer-debt.blade.php` modals | 🟢 |
| Performance | Ledger list bounded to 20 rows/page; originating order eager-loaded (`with('order')`) to avoid N+1 | `CustomerController.php:274-277` | 🟢 |
| Auditability | Every entry persists `balance_after` + `created_by`, preserving a full trail even after the source order is hard-deleted | `migration:18,20,24`, `CustomerDebt.php:57-59` | 🟢 |
| Observability | None — no log/metric/trace on either the read page or the two balance-mutating writes | `CustomerController.php:263-425` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Cenário: Ver o extrato de dívida de um cliente
  Dado um administrador autenticado e um cliente existente com lançamentos de dívida
  Quando ele acessa GET /customers/{id}/debt
  Então recebe HTTP 200 com a página customer-debt mostrando o debt_total ao vivo e o extrato customer_debts paginado (20 por página, id desc), com o pedido de origem carregado para linhas pos_debt

Cenário: Registrar uma dívida manual
  Dado um cliente com debt_total = 50
  Quando o admin envia POST /customers/{id}/debts com amount=100
  Então debt_total passa a 150, um lançamento manual_debt é gravado com balance_after=150 e created_by = id do admin, e o admin é redirecionado de volta com "Ghi nợ thành công"

Cenário: Registrar um pagamento válido
  Dado um cliente com debt_total = 150
  Quando o admin envia POST /customers/{id}/repayments com amount=30
  Então debt_total passa a 120 e um lançamento repayment é gravado com balance_after=120

Cenário: Pagamento maior que o saldo é rejeitado
  Dado um cliente com debt_total = 100
  Quando o admin envia POST /customers/{id}/repayments com amount=150
  Então nenhum lançamento é criado, debt_total permanece 100, e o admin volta com o erro "Số tiền thu nợ vượt quá số dư nợ hiện tại (100,0 ₫)"

Cenário: Valor inválido
  Dado um administrador autenticado
  Quando ele envia amount=0 (ou negativo/não numérico) para /debts ou /repayments
  Então a validação falha e nenhum lançamento é gravado

Cenário: Cliente inexistente
  Dado um id de cliente inexistente ou soft-deletado
  Quando qualquer uma das três rotas é chamada
  Então recebe HTTP 404

Cenário: Requisição não autenticada
  Dado nenhuma sessão de admin autenticada
  Quando qualquer uma das três rotas é chamada
  Então recebe HTTP 302 redirecionando para auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| Record manual debt / repayment through the locked `record` primitive (RF-04, RF-05, RF-08) | Must | The core A/R write path; correctness of the customer balance depends on it |
| Reject over-balance repayment (RF-06) | Must | Prevents a negative balance / accounting error |
| Resolve/guard the customer (RF-02) | Must | No debt action without a valid customer; `findOrFail` is the only guard |
| Render the ledger page (RF-01, RF-03) | Must | The staff-facing view of the balance and its history |
| Validate amount/note (RF-07) | Must | First line of defence before the balance mutation |
| Capture `created_by` (RF-09) | Should | Audit/provenance; the write still succeeds if it is null |
| Admin authentication (RF-10) | Must | Enforced by the route group for the whole back office |

> Priority inferred from the endpoints' role as the customer accounts-receivable write path plus its read view.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/CustomerController.php:263-280` | `CustomerController::debt` (ledger page) | 🟢 |
| `app/Http/Controllers/CustomerController.php:379-399` | `CustomerController::storeDebt` (manual debt) | 🟢 |
| `app/Http/Controllers/CustomerController.php:401-425` | `CustomerController::storeRepayment` (repayment) | 🟢 |
| `routes/web.php:58,60,61` | `customers.debt`, `customers.debts.store`, `customers.repayments.store` (declared before `resource('/customers')`) | 🟢 |
| `app/Models/CustomerDebt.php:38-64` | `CustomerDebt::record` (locked, transactional balance mutation + ledger insert) | 🟢 |
| `app/Models/CustomerDebt.php:10-30` | `CustomerDebt` `$fillable`, `customer()`/`order()`/`relatedDebt()` relations | 🟢 |
| `app/Models/Customer.php:15-19,39-42` | `Customer` (`debt_total` fillable + float cast, `debts()` hasMany) | 🟢 |
| `database/migrations/2026_08_28_000001_create_customer_debts_table.php` | `customer_debts` schema + FKs | 🟢 |
| `resources/views/pages/customer-debt.blade.php` | ledger table + repayment/manual-debt modals | 🟢 |
