# Customers Debt Actions — Contracts

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

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

External HTTP contracts exposed by the `customers-debt-actions` unit — three routes under the admin group (`['web','admin']`, empty admin prefix): the read-only ledger page `GET /customers/{customer}/debt` (`customers.debt`) and the two balance-mutating writes `POST /customers/{customer}/debts` (`customers.debts.store`) and `POST /customers/{customer}/repayments` (`customers.repayments.store`). All three require an authenticated admin session; an unauthenticated request gets `302 → auth/login`. The two writes are **web form** endpoints (redirect + flash / redirect-with-errors), not JSON APIs. 🟢 (`routes/web.php:58,60,61`, `CustomerController.php:263-280,379-425`)

> These routes are declared **before** `resource('/customers', 'CustomerController')` (`routes/web.php:62`), so none is captured by the resource routes. The resource CRUD belongs to `customers-crud`; the sibling `/customers/{customer}/*` screens (`orders`, `statistic`, `check-gift`, `gift-received`, `redeem-points`) belong to `customers-purchase-history`, `customers-statistics`, and `customers-loyalty`. The read-only `/debts` overview belongs to the `debts` unit. 🟢 (`routes/web.php:53-67`)

---

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

- **Auth:** required; anonymous → `302 auth/login`. 🟢 (`routes/web.php:23-28`)
- **Route parameter:**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `customer` | integer | ✅ | Customer id. `Customer::findOrFail` → `404` if unknown or soft-deleted. 🟢 (`:271`) |

- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `page` | integer | ❌ | Standard Laravel paginator page (`paginate(20)`). Out-of-range pages render an empty ledger. 🟢 (`:277`) |

- **Response — `200 text/html`** rendering `pages.customer-debt` with:

  | View variable | Type | Meaning |
  |---------------|------|---------|
  | `item` | `Customer` | The resolved customer. 🟢 (`:271`) |
  | `debtTotal` | numeric | **Live** `customer.debt_total` (float cast), `?? 0` — always current, not a nightly snapshot. 🟢 (`:272`) |
  | `debtOrders` | `LengthAwarePaginator<CustomerDebt>` | The customer's ledger entries, `id` desc, 20/page, each with its originating `order` eager-loaded (`with('order')`). 🟢 (`:274-277`) |

- **Ledger row shape (`CustomerDebt`):** `id`, `customer_id`, `order_id` (nullable — null once the order is hard-deleted), `related_debt_id` (nullable — a `debt_void` → its `pos_debt`), `type ∈ {pos_debt, manual_debt, repayment, debt_void}`, `amount` (decimal 15,1), `balance_after` (decimal 15,1, nullable), `note`, `created_by`, timestamps. The view renders a type label, a signed amount (`−` for `repayment`/`debt_void`, `+` otherwise), and `balance_after`. 🟢 (`migration:12-21`, `customer-debt.blade.php`)
- **Status codes:** `200` (page rendered) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). No JSON variant. 🟢 (`:263-280`)

---

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

- **Auth:** required; anonymous → `302 auth/login`. **CSRF:** required (web `Form::open` token). 🟢 (`customer-debt.blade.php`)
- **Route parameter:** `customer` (integer, ✅) — `Customer::findOrFail` → `404`. 🟢 (`:386`)
- **Request body (form-encoded):**

  | Field | Type | Required | Rule | Notes |
  |-------|------|----------|------|-------|
  | `amount` | numeric | ✅ | `required\|numeric\|min:0.01` | Debt to add. Cast `(float)` before `record`. Stored as decimal(15,1) — sub-0.1 values round. 🟢 (`:382,388`) |
  | `note` | string | ❌ | `nullable\|string` | Free-text reason, stored on the entry. 🟢 (`:383`) |

- **Effect:** `CustomerDebt::record($customer, 'manual_debt', (float)$amount, null, $note ?? null, Admin::user()?->id)` — **increases** `debt_total`, inserts one `manual_debt` entry with `balance_after`, all in a locked transaction. 🟢 (`:388-395`, `CustomerDebt.php:38-64`)
- **Response — `302` redirect back:** success flashes `admin_toastr('Ghi nợ thành công')`. 🟢 (`:397-398`)
- **Failure:** validation failure → `302` back with errors (framework). No try/catch — a `manual_debt` add never throws in `record`. 🟢 (`:382-392`)
- **Status codes:** `302` (redirect back, success or validation error) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). Not JSON / not content-negotiated (contrast `customers-crud` `store`). 🟢

---

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

- **Auth:** required; anonymous → `302 auth/login`. **CSRF:** required. 🟢 (`customer-debt.blade.php`)
- **Route parameter:** `customer` (integer, ✅) — `Customer::findOrFail` → `404`. 🟢 (`:408`)
- **Request body (form-encoded):**

  | Field | Type | Required | Rule | Notes |
  |-------|------|----------|------|-------|
  | `amount` | numeric | ✅ | `required\|numeric\|min:0.01` | Amount paid down. Cast `(float)`. 🟢 (`:404,411`) |
  | `note` | string | ❌ | `nullable\|string` | Free-text, stored on the entry. 🟢 (`:405`) |

- **Effect:** `CustomerDebt::record($customer, 'repayment', (float)$amount, null, $note ?? null, Admin::user()?->id)` inside a try/catch — under the lock, `record` **rejects** the write if `amount > debt_total` (throws), otherwise **decreases** `debt_total` and inserts one `repayment` entry with `balance_after`. 🟢 (`:410-418`, `CustomerDebt.php:43-47`)
- **Response — `302` redirect back:** success flashes `admin_toastr('Thu nợ thành công')`. 🟢 (`:423-424`)
- **Over-balance failure:** `record` throws `"Số tiền thu nợ vượt quá số dư nợ hiện tại (<balance> ₫)"`; the catch returns `302` back with that error and **no** ledger row (transaction rolled back). 🟢 (`CustomerDebt.php:45`, `:419-421`)
- **Status codes:** `302` (redirect back — success, validation error, or over-balance error) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). Not JSON. 🟢

---

## Shared write invariant — `CustomerDebt::record`

Both POST endpoints funnel through one primitive, so the contract they honour is identical downstream:

- Runs in a `DB::transaction` with `Customer::lockForUpdate()` on the target row — concurrent writes on the same customer are **serialised**; two repayments cannot both pass the balance check. 🟢 (`CustomerDebt.php:40-41`)
- `manual_debt` (and `pos_debt`/`debt_void` from other units) **add**; `repayment` **subtracts**; a repayment above the current balance is rejected. 🟢 (`CustomerDebt.php:43-50`)
- Every entry persists `balance_after`, giving an audit trail that survives the source order being hard-deleted (`order_id` set null). 🟢 (`CustomerDebt.php:57`, `migration:24`)

---

## Consumed contracts (owned by other units)

`customers-debt-actions` reads/writes the `customers` and `customer_debts` tables through their models and reads `Admin::user()`. It calls no other unit's HTTP endpoint and no external service. 🟢

| Reads / writes | Owner unit | Purpose |
|----------------|------------|---------|
| `customers` (`findOrFail`, `debt_total`, row lock) | `customers-crud` | resolve the customer and mutate the denormalised balance |
| `customer_debts` (insert `manual_debt`/`repayment`; read ledger `with('order')`) | **this unit** (writer of these two types) | the A/R ledger |
| `orders` (`order()` belongsTo, eager-loaded for `pos_debt` rows) | `orders-crud` | display the originating order code/total on the ledger page |
| `Admin::user()` | `auth` | `created_by` provenance on each write |

---

## Producer/consumer relationships

| This unit is… | Counterparty | Contract |
|---------------|--------------|----------|
| **Producer** | `customer_debts` ledger | originates `manual_debt` and `repayment` entries and maintains `customers.debt_total`. 🟢 |
| **Consumer** | `customers-crud` | reached from the customer detail tabs; depends on the `Customer` model + `debt_total`. 🟢 |
| **Consumer** | `debts` (overview) | the `/debts` page's action buttons POST to these two write endpoints. 🟢 (`debts` unit, `debts.blade.php`) |
| **Peer producer** | `orders-crud` | writes the other two ledger types (`pos_debt` on a debt-bearing `done` order, `debt_void` on order `destroy` via `CustomerDebt::voidForOrder`) into the same ledger this unit reads. 🟢 (`CustomerDebt.php:83-144`) |
| **Consumer** | `auth` | `Admin::user()` for `created_by`. 🟢 |

---

## Cross-cutting contract notes

- **Method surface:** one `GET` (ledger page) and two `POST` (writes); no `PUT`/`DELETE`/`PATCH` — the ledger is append-only, entries are never edited or deleted through this unit. 🟢 (`routes/web.php:58,60,61`)
- **Content types:** `text/html` for the ledger page; `302` redirect-back for both writes (web forms), **not** JSON — contrast the JSON `customers-scan` / `customers-loyalty` `check-gift`. 🟢
- **Idempotency:** the writes are **not** idempotent — each successful POST appends a new ledger entry and shifts the balance; there is no client-supplied dedup key. 🟡
- **Concurrency:** guaranteed serialisable per customer via `lockForUpdate` inside `record`. 🟢 (`CustomerDebt.php:41`)
- **Validation floor vs storage:** `amount ≥ 0.01` but `decimal(15,1)` storage rounds sub-0.1 values — a legal input can record a `0.0` entry. 🟡 (`:382,404`, `migration:17`)
- **CSRF:** required on both POSTs (Laravel token via `Form::open`). 🟢
- **Authorization:** authentication only; any admin may add debt to / record a repayment for any customer (no per-record scope). 🟡 (ADR-0009)
- **No observability:** none of the three routes emit telemetry; the only audit is the persisted `balance_after` + `created_by` on each row. 🔴 (`:263-425`, absence)
