# Customers Debt Actions — Technical Design

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

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

## Interface

HTTP endpoints (admin group, empty prefix, middleware `['web','admin']`):

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/customers/{customer}/debt` | `customer: int` (route), `page: int` (query, optional) | HTML page `pages.customer-debt` | 200; 404 (unknown/soft-deleted); 302 → `auth/login` |
| POST | `/customers/{customer}/debts` | `customer: int` (route), `amount: numeric ≥0.01`, `note: string?` (body) | `302` redirect back (+ toastr) or `302` back with validation errors | 302; 404; 422-style redirect-with-errors |
| POST | `/customers/{customer}/repayments` | `customer: int` (route), `amount: numeric ≥0.01`, `note: string?` (body) | `302` redirect back (+ toastr) or `302` back with errors | 302; 404; 302-back-with-errors (over-balance) |

Controller symbols:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `CustomerController::debt` | `debt($id)` | `\Illuminate\View\View` | Read-only; renders `item`, `debtTotal`, `debtOrders`. 🟢 (`:263-280`) |
| `CustomerController::storeDebt` | `storeDebt($customerId, Request $request)` | `RedirectResponse` | Records a `manual_debt`; no try/catch (record never throws for adds). 🟢 (`:379-399`) |
| `CustomerController::storeRepayment` | `storeRepayment($customerId, Request $request)` | `RedirectResponse` | Records a `repayment`; try/catch converts an over-balance exception into a back-with-errors redirect. 🟢 (`:401-425`) |

Domain primitive:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `CustomerDebt::record` | `record(Customer $customer, string $type, float $amount, $orderId=null, $note=null, $createdBy=null)` | `void` | Transactional; `lockForUpdate` on the customer; throws for a repayment `> debt_total`. 🟢 (`CustomerDebt.php:38-64`) |

All three routes are registered at `routes/web.php:58,60,61` **before** `resource('/customers', 'CustomerController')` (`:62`), so `/customers/{customer}/debt`, `.../debts`, and `.../repayments` are matched by these actions and never shadowed by the resource routes. 🟢

## Main Flow

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

1. Set the header `__('Công nợ')` ("Debt") and a two-level breadcrumb (`Khách hàng` → header). 🟢 (`:265-269`)
2. Resolve the customer: `$item = Customer::findOrFail($id)` → `404` if unknown or soft-deleted. 🟢 (`:271`)
3. Read the **live** balance: `$debtTotal = $item->debt_total ?? 0` (float cast on the model). 🟢 (`:272`, `Customer.php:17-19`)
4. Load the ledger: `$debtOrders = CustomerDebt::with('order')->where('customer_id', $item->id)->orderBy('id','desc')->paginate(20)` — newest first, 20/page, originating order eager-loaded so `pos_debt` rows render the order code/total without an extra query. 🟢 (`:274-277`)
5. Render `return $this->view('pages.customer-debt', compact('item','debtTotal','debtOrders'))`. The view is a tab in the customer detail shell, with a repayment modal (posts to `customers.repayments.store`) and a manual-debt modal (posts to `customers.debts.store`). 🟢 (`:279`, `customer-debt.blade.php`)

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

1. Validate `['amount' => 'required|numeric|min:0.01', 'note' => 'nullable|string']`. 🟢 (`:381-384`)
2. Resolve `$customer = Customer::findOrFail($customerId)` → `404`. 🟢 (`:386`)
3. Call `CustomerDebt::record($customer, 'manual_debt', (float)$valided['amount'], null, $valided['note'] ?? null, Admin::user() ? Admin::user()->id : null)` — increases `debt_total`, inserts one `manual_debt` row with `balance_after`. **No** try/catch (a `manual_debt` add never throws). 🟢 (`:388-395`)
4. `admin_toastr(__('Ghi nợ thành công'))` and `redirect()->back()`. 🟢 (`:397-398`)

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

1. Same validation as manual debt. 🟢 (`:403-406`)
2. Resolve `$customer = Customer::findOrFail($customerId)` → `404`. 🟢 (`:408`)
3. Inside a try/catch, call `CustomerDebt::record($customer, 'repayment', (float)$valided['amount'], null, note, createdBy)` — under the lock, `record` throws if `amount > debt_total`, otherwise decreases the balance and inserts one `repayment` row. 🟢 (`:410-418`)
4. **On exception:** `return redirect()->back()->withErrors($e->getMessage())` (the over-balance message). 🟢 (`:419-421`)
5. **On success:** `admin_toastr(__('Thu nợ thành công'))` and `redirect()->back()`. 🟢 (`:423-424`)

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

1. Open `DB::transaction`. 🟢 (`:40`)
2. `$locked = Customer::lockForUpdate()->find($customer->id)` — a pessimistic row lock serialising concurrent writers on this customer. 🟢 (`:41`)
3. If `type === 'repayment'`: if `amount > $locked->debt_total`, `throw new \Exception('Số tiền thu nợ vượt quá số dư nợ hiện tại (' . number_format($locked->debt_total, 1) . ' ₫)')`; else `$locked->debt_total -= $amount`. Otherwise (`pos_debt`/`manual_debt`/`debt_void` add path) `$locked->debt_total += $amount`. 🟢 (`:43-50`)
4. `static::create([...])` writing `customer_id`, `order_id`, `type`, `amount`, `balance_after => $locked->debt_total`, `note`, `created_by`. 🟢 (`:52-60`)
5. `$locked->save()` persists the new balance. Commit closes the transaction. 🟢 (`:62`)

## Alternative Flows

- **Unknown / soft-deleted customer:** `findOrFail` throws `ModelNotFoundException` → framework `404` on all three routes. 🟢 (`:271,386,408`)
- **Invalid amount:** `amount` missing, non-numeric, or `< 0.01` → Laravel validation redirects back with errors before `record` runs; balance untouched. 🟢 (`:381-384,403-406`)
- **Repayment over balance:** `record` throws; only `storeRepayment` catches it → back-with-errors. `storeDebt` would surface an unhandled exception, but `manual_debt` never takes the throwing branch. 🟢 (`:43-47,410-421`)
- **`pos_debt` row whose order was deleted:** `order_id` is `set null` on order delete, so `with('order')` yields `null`; the view shows the entry note or "Đơn hàng đã bị xóa". 🟢 (`migration:24`, `customer-debt.blade.php`)
- **Concurrent repayments:** the second writer blocks on the customer row lock until the first commits, then re-reads the reduced `debt_total`, so two repayments cannot both pass the `> debt_total` check. 🟢 (`CustomerDebt.php:41-47`)
- **Empty ledger:** `paginate(20)` yields an empty paginator; the view prints "Chưa có phát sinh công nợ nào của khách hàng này." 🟢 (`customer-debt.blade.php`)
- **Unauthenticated:** the admin route group middleware redirects to `auth/login` before any action runs. 🟢 (`routes/web.php:23-28`)

## Dependencies

- **`App\Models\Customer`** — resolved by route id via `findOrFail` (SoftDeletes → deleted = `404`); holds the live `debt_total` (float cast) and the `debts()` `hasMany` relation; row-locked inside `record`. 🟢 (`Customer.php:9-19,39-42`)
- **`App\Models\CustomerDebt`** — the ledger model and the `record` primitive; `with('order')` uses its `order()` `belongsTo`. `$fillable` includes `order_id`, `related_debt_id`, `type`, `amount`, `balance_after`, `note`, `created_by`. 🟢 (`CustomerDebt.php:10-64`)
- **`App\Models\Order`** — eager-loaded per `pos_debt` entry for the code/total display; not written here. 🟢 (`CustomerDebt.php:17-20`)
- **`Encore\Admin\Facades\Admin`** — `Admin::user()` supplies `created_by` (null-safe ternary). 🟢 (`:394,417`)
- **`admin_toastr` helper** — success flash on both writes. 🟢 (`:397,423`)
- **Admin route group** (`config('admin.route.*')`, middleware `['web','admin']`) — enforces the authenticated-admin precondition, the shared admin layout used by `$this->view`, and CSRF for the POST forms. 🟢 (`routes/web.php:23-28`)

## Design Decisions Identified

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| A single locked, transactional `record` primitive is the only path that mutates `debt_total` (both write endpoints, plus `pos_debt`/`debt_void` from orders) | `CustomerDebt.php:38-64`; `:388,411` | 🟢 |
| Balance kept **denormalised** on `customers.debt_total` and mutated in lockstep with each ledger insert, rather than derived on read from `Σ` ledger | `CustomerDebt.php:47-62`, `Customer.php:15-19` | 🟢 |
| Append-only ledger with per-entry `balance_after` snapshot for a full audit trail | `migration:18`, `CustomerDebt.php:57` | 🟢 |
| Ledger page reads the **live** `debt_total` (not the nightly summary) so the balance is always current | `:272` vs `customers-statistics` | 🟢 |
| Repayment guarded (try/catch) but manual debt not — only the subtracting path can fail the balance invariant | `:410-421` vs `:388-398` | 🟢 |
| Provenance via a bare `created_by` integer (admin id), no FK to `admin_users` | `migration:20`, `:394,417` | 🟡 |
| Named routes declared before the resource so the `/{customer}/*` verbs are not shadowed by resource `show`/`update` | `routes/web.php:58-62` | 🟢 |

## Internal State

The two write actions are stateless request handlers; the mutated state lives entirely in the database. Per write, the transaction touches exactly two rows/tables: it updates `customers.debt_total` for the target customer and inserts one row into `customer_debts` with a `balance_after` snapshot. The customer row is held under `lockForUpdate` for the duration of the transaction, serialising concurrent writers. The ledger page holds no state — it reads the customer and a page of ledger rows and renders. 🟢 (`CustomerDebt.php:40-63`, `:263-280`)

## Observability

None. Neither the ledger page nor the two balance-mutating writes emit any log, metric, or trace. A rejected repayment, a successful manual debt, and a failed transaction are all invisible outside the DB and the redirect/flash message. `created_by` + `balance_after` provide an in-table audit trail but no runtime telemetry. 🔴 (`:263-425`, absence)

## Risks and Gaps

- 🟡 **`amount` floor vs storage precision.** Validation allows `amount ≥ 0.01`, but `customer_debts.amount` and `balance_after` are `decimal(15,1)` (one decimal place), so an amount like `0.04` is accepted then rounded to `0.0` on storage — a legal input that records a no-op-looking entry. Confirm the intended minimum granularity (₫ amounts are typically whole numbers). (`:381,403`, `migration:17-18`)
- 🟡 **`created_by` is an unconstrained integer.** No FK to `admin_users`; a deleted/renamed admin leaves a dangling id, and `null` is stored when no admin resolves. Fine for a single-admin deployment (ADR-0009) but weak provenance. (`migration:20`, `:394,417`)
- 🟡 **No per-record authorization.** Any authenticated admin can add debt to, or record a repayment for, any customer — no ownership/scope check (system-wide authentication-only model, ADR-0009).
- 🟡 **Manual debt has no upper bound.** `manual_debt` only validates `min:0.01`; a mistyped large amount inflates the balance with no confirmation step (only a repayment/`debt_void` can bring it back down). (`:381-395`)
- 🔴 **No observability** on a money-mutating path — no audit log beyond the ledger row itself, no alerting on rejected repayments or transaction failures.
- 🟢 **Concurrency is handled.** The `lockForUpdate` + transaction in `record` correctly serialises concurrent writes and prevents an overdraw race — no gap here.
