## Context

Hiện tại hệ thống không có khái niệm "nợ": `OrderController@store`/`@update` luôn set `$order->paid = $order->total`, và bảng `orders` không có cột nào phản ánh phần chưa thanh toán. `Customer` model đã có một pattern cộng dồn trực tiếp cho `points` (`$customer->points += $order->earned_point; $customer->save();`) — không tính lại bằng `SUM()`.

Đã có một nỗ lực ghi nợ dở dang trước đây: cột `customer_order_summary.owe_total` được thêm qua migration, và `CustomerController@statis` đọc ra biến `$oweTotal`, nhưng (a) view `customer-statis.blade.php` không render biến này, và (b) job `Order::summaryLogging()` chạy `daily` qua `Kernel.php` có câu `ON DUPLICATE KEY UPDATE owe_total = VALUES(owe_total)` trong khi `owe_total` không nằm trong danh sách cột được `SELECT`/`INSERT` — nên `VALUES(owe_total)` luôn trả về giá trị mặc định (`0`), tức là job này sẽ liên tục ghi đè `owe_total` về 0 nếu có dữ liệu. Cột này bị bỏ ngoài phạm vi thay đổi lần này (không sửa, không xoá), tính năng mới dùng cấu trúc dữ liệu hoàn toàn riêng để tránh lặp lại lỗi tương tự.

## Goals / Non-Goals

**Goals:**
- Ghi nhận mọi phát sinh/giảm nợ (ghi nợ tại POS, ghi nợ tay, thu nợ) vào một ledger có audit trail.
- Giữ một số dư nợ hiện tại (`customers.debt_total`) luôn đồng bộ với ledger, đọc nhanh không cần `SUM()`.
- Đảm bảo không thể thu nợ vượt quá số dư đang nợ, kể cả khi có yêu cầu đồng thời.
- Cung cấp màn hình tổng hợp công nợ theo khách hàng, có thể xem các đơn hàng liên quan.

**Non-Goals:**
- Không sửa/xoá/dùng lại `customer_order_summary.owe_total`.
- Không thêm khái niệm nợ quá hạn, lãi suất, hay nhắc nợ tự động.
- Không thay đổi cách `orders.paid`/`orders.total` hoạt động — nợ là một luồng dữ liệu song song, không phải một phần của công thức thanh toán đơn hàng.
- Không thêm phân quyền/role mới — dùng chung cơ chế đăng nhập admin (Encore Admin) hiện có, không giới hạn thêm ai được ghi nợ/thu nợ.

## Decisions

### 1. Ledger (`customer_debts`) là nguồn sự thật, `customers.debt_total` là cache
Thay vì chỉ lưu một số dư chạy trên `customers`, dùng ledger + cache. Ledger cho audit trail (ai ghi, khi nào, gắn đơn nào) và cho khả năng đối soát/tính lại nếu cache bị lệch. Cache giúp màn Danh sách công nợ đọc nhanh, tránh `GROUP BY`/`SUM()` trên nhiều khách hàng mỗi lần load — theo đúng pattern `customer.points` đã có.

Schema `customer_debts`:
- `customer_id` (FK, bắt buộc)
- `order_id` (FK, nullable — chỉ có giá trị khi `type = pos_debt`)
- `type`: enum `pos_debt` | `manual_debt` | `repayment`
- `amount`: decimal, luôn lưu giá trị dương
- `note`: string, nullable (lý do ghi nợ tay / thu nợ)
- `created_by`: nullable, id của admin thực hiện thao tác
- timestamps

### 2. Một điểm ghi duy nhất cho mọi thay đổi số dư
Toàn bộ logic "tạo ledger entry + cộng/trừ `debt_total`" được gom vào một chỗ duy nhất (ví dụ static method trên `CustomerDebt`, chạy trong `DB::transaction`), thay vì để từng controller tự cập nhật `debt_total` riêng lẻ. Đây chính là điều đã thiếu ở `owe_total` cũ — không có nơi nào đảm bảo cache luôn khớp với nguồn dữ liệu gốc. Có một điểm ghi duy nhất giúp cache không bao giờ lệch khỏi ledger.

### 3. Sau khi đơn đã `done`, "Tiền nợ" bị khoá — không cho sửa lại nữa
Quyết định ban đầu (điều chỉnh `pos_debt` entry theo phần chênh lệch mỗi lần sửa đơn `done`) đã bị loại bỏ sau khi phát hiện lỗi: `customers.debt_total` là tổng nợ **của cả khách hàng**, không tách riêng theo từng đơn. Nếu khách đã được thu nợ một phần (từ bất kỳ nguồn nào) sau khi đơn được ghi nợ, rồi sau đó số nợ gốc của đơn đó bị sửa lại → điều chỉnh `debt_total` theo phần chênh lệch sẽ không biết gì về khoản đã thu, có thể đẩy `debt_total` xuống âm hoặc sai lệch thực tế so với những gì đã thu.

Quyết định mới: một khi đơn đã từng có `pos_debt` entry được ghi, input "Tiền nợ" khi sửa đơn đó sau này sẽ bị khoá — server bỏ qua giá trị `debt_amount` gửi lên, không đụng gì đến ledger hay `debt_total`; FE cũng disable input tương ứng để không gây hiểu lầm. Muốn điều chỉnh nợ sau khi đơn đã hoàn tất thì dùng "Ghi nợ tay" (cộng thêm) hoặc "Thu nợ" (giảm bớt) — cả hai đều là ghi thêm dòng mới vào ledger chứ không sửa lại số cũ, nên không thể tạo ra trạng thái sai lệch.

Trường hợp đơn đang `draft` rồi chuyển thành `done` lần đầu (kể cả qua nút "Tạo đơn" ở trang chi tiết đơn hàng) thì vẫn cho nhập/sửa `debt_amount` bình thường và ghi `pos_debt` entry đầu tiên, vì lúc đó chưa có ledger entry nào để xung đột.

**Quan trọng — tín hiệu khoá KHÔNG dựa vào trạng thái hiện tại của đơn (`status=done`), mà dựa vào việc ledger đã có entry hay chưa.** Nghiệp vụ cho phép chuyển đơn `done` ngược về `draft` trong khung giờ còn `is_editable` (workflow cần thiết, không bỏ). Nếu tín hiệu khoá chỉ dựa vào "đơn đang `done`", thì khi đơn bị chuyển `done → draft`, khoá sẽ tự động mở ra — và nếu sau đó đơn được chuyển `draft → done` lại, hệ thống sẽ tưởng đây là lần đầu hoàn tất và ghi thêm một `pos_debt` entry nữa, cộng dồn nợ sai (double-count). Vì vậy: `Order::debt_locked` (accessor mới) kiểm tra thẳng `customer_debts` — *"đơn này đã có `pos_debt` entry chưa, bất kể trạng thái hiện tại là gì"* — nhờ đó phân biệt được hai loại đơn nháp trông giống nhau: một đơn `draft` **chưa từng** qua `done` (chưa có entry, `debt_amount` vẫn sửa tự do) khác với một đơn `draft` **đã từng** qua `done` rồi bị chuyển ngược lại (đã có entry, `debt_amount` bị khoá vĩnh viễn dù đơn không còn ở trạng thái `done` nữa). FE nhận tín hiệu này qua field `debt_locked` được append vào JSON của `Order` (không dùng lại `status === 'done'` như phiên bản đầu).

Cộng điểm thưởng (`customer.points += order.earned_point`) mắc đúng lỗi tương tự — điều kiện cộng cũ chỉ dựa vào `$request->status === 'done'` của request hiện tại, không nhớ đã từng cộng cho đơn này chưa, nên `draft → done → draft → done` cũng cộng điểm 2 lần. Áp dụng cùng nguyên tắc: thêm cột `orders.points_awarded_at` (timestamp, nullable) — cộng điểm đúng một lần duy nhất trong đời của đơn (khi cột này còn `null` và đơn chuyển sang `done`), set cột này ngay sau đó, không bao giờ cộng lại nữa dù đơn có nhảy qua lại trạng thái bao nhiêu lần. Đã cân nhắc phương án "revert điểm/nợ khi đơn rời khỏi `done`" nhưng loại bỏ: nếu khách đã dùng điểm đó đổi quà (tính năng đổi quà đang có sẵn, không có ledger riêng cho điểm), revert có thể trừ nhầm vào điểm không liên quan hoặc làm âm — rủi ro giống hệt lớp lỗi đã né ở nợ, nhưng không có cách khắc phục an toàn tương đương vì thiếu ledger điểm.

### 3b. Cột `orders.debt_amount` — giá trị hiển thị/tiếp diễn trên từng đơn, tách khỏi ledger
Thêm cột `debt_amount` trực tiếp trên bảng `orders` (cùng vai trò với `discount_amount` đã có sẵn) để lưu số tiền nợ được nhập cho đơn đó, bất kể đơn đang `draft` hay `done`. Cột này phục vụ 3 mục đích:
- Đơn `draft` lưu nợ đã nhập để khi mở lại sửa vẫn thấy đúng giá trị đã gõ, dù việc ghi vào ledger/`debt_total` vẫn chỉ xảy ra khi đơn `done` (tách "hiển thị" khỏi "hạch toán").
- Trang chi tiết đơn hàng và form sửa đơn đọc thẳng cột này để hiển thị/tiền điền, không cần join sang `customer_debts`.
- Khi một `draft` đã có sẵn `debt_amount` được hoàn tất qua nút "Tạo đơn" (không có input `debt_amount` nào được gửi trong request đó), giá trị cũ trên `orders.debt_amount` được dùng làm mặc định thay vì coi như 0.

Cột này **không phải nguồn sự thật cho kế toán nợ** — `customer_debts` + `customers.debt_total` vẫn giữ vai trò đó. Một khi đơn đã `done` (decision #3), `orders.debt_amount` cũng bị khoá theo, không bị ghi đè bởi các lần sửa sau.

### 3c. Số tiền nợ không được vượt quá tổng tiền hàng của đơn
Thêm ràng buộc: `debt_amount` nhập vào không được lớn hơn `order.total` (tổng tiền hàng sau giảm giá) của đơn đó. Áp dụng cho cả tạo mới và sửa đơn (khi chưa bị khoá theo decision #3).

Cách này giữ quan hệ 1 đơn hàng ↔ tối đa 1 `pos_debt` entry (được tạo đúng một lần khi đơn chuyển sang `done`), để "danh sách đơn hàng liên quan" ở màn công nợ không bị trùng lặp dòng cho cùng một đơn.

### 4. Chặn thu nợ vượt số dư bằng khoá dòng (row lock), không chỉ validate ở tầng ứng dụng
Chỉ validate `amount <= debt_total` trước khi ghi là chưa đủ nếu có 2 request thu nợ chạy đồng thời — cả hai có thể cùng đọc số dư cũ và cùng pass. Vì vậy thao tác thu nợ (và ghi nợ nói chung) khoá dòng `customers` bằng `lockForUpdate()` bên trong transaction trước khi đọc `debt_total` để validate và ghi.

### 5. POS: input "Tiền nợ" là giá trị nhập tay độc lập, gắn với `customerKey`
Không tính theo công thức `total - paid`. Ở FE, input được bind theo cùng cơ chế binding hiện có (`data-model`) như ô "Tiền giảm", nhưng bị disable cho tới khi `fillCustomerInfo()` nhận được khách hàng có `id`. Ở BE, validate: nếu `debt_amount > 0` mà không resolve được `customer` hợp lệ từ `customer.phone` thì reject. Việc validate `debt_amount` (bắt buộc có khách hàng, không vượt quá `total`) còn được lặp lại ở FE ngay khi bấm nút submit (trước khi form thực sự gửi đi), để tránh trường hợp phải chờ round-trip lên server rồi load lại trang mới biết lỗi — vốn sẽ làm mất toàn bộ giỏ hàng đang nhập dở vì POS không phục hồi lại state từ `old()` sau khi trang được server render lại.

### 6. Xoá đơn hàng có nợ liên kết: huỷ nợ theo `min(entry, debt_total)`, không xoá âm thầm và không chặn cứng
Khi xoá một đơn (`OrderController@destroy`) đang có `pos_debt` entry với `amount > 0`, hệ thống KHÔNG chặn xoá và cũng KHÔNG âm thầm bỏ qua khoản nợ đó. Trước khi xoá đơn, `CustomerDebt::voidForOrder($order)` chạy trong transaction (khoá dòng `customers` bằng `lockForUpdate()`):
- Giảm `debt_total` của khách theo `min(tổng amount của các pos_debt entry gắn với đơn này, debt_total hiện tại)` — **không phải trừ thẳng theo `amount` gốc**, vì nếu khách đã trả bớt nợ (repayment) ở đâu đó từ sau khi đơn này được ghi, `debt_total` hiện tại có thể đã thấp hơn `amount` gốc; trừ thẳng sẽ đẩy `debt_total` xuống âm — lặp lại đúng lỗi đã né ở decision #3.
- Đặt `amount = 0` cho các entry đó (giữ lại dòng, không xoá hẳn, để còn dấu vết trong lịch sử là khoản nợ này đã bị huỷ do đơn gốc bị xoá).

Ở FE, hộp thoại xác nhận xoá đơn hiển thị rõ số tiền nợ sẽ bị huỷ khi đơn đó thực sự có `pos_debt` entry (`$item->debt_locked`, không phải chỉ dựa vào `debt_amount > 0` — một đơn `draft` chưa từng qua `done` có thể có `debt_amount` đã nhập nhưng chưa hề ảnh hưởng tới `debt_total`, xoá nó không cần cảnh báo gì), thay vì thông báo xoá chung chung — người dùng biết trước hệ quả trước khi bấm xác nhận, thay vì bị chặn hoàn toàn.

**Điểm thưởng cũng bị "mồ côi" giống hệt nợ khi xoá đơn — đã xử lý theo cùng nguyên tắc.** Nếu đơn từng cộng điểm (`points_awarded_at` khác null) rồi bị xoá, `customer.points` trước đây vẫn giữ nguyên phần điểm từ đơn không còn tồn tại. `Customer::reversePointsForOrder($order)` chạy song song với `voidForOrder()` (transaction + `lockForUpdate()` riêng), trừ `customer.points` theo `max(0, points - earned_point)` — chặn ở 0 thay vì trừ thẳng, vì khách có thể đã dùng một phần điểm đó đổi quà (không có ledger cho điểm để biết chính xác phần nào đã tiêu). Hộp thoại xác nhận xoá cũng liệt kê số điểm sẽ bị thu hồi khi có.

### 7. Màn Danh sách công nợ: tách "xem" khỏi "thao tác"; chi tiết nợ là 1 tab trên trang khách hàng, không phải trang riêng
Thiết kế ban đầu đặt nút "Thu nợ"/"Ghi nợ tay" trên từng dòng khách hàng ở màn danh sách, và "Danh sách đơn hàng liên quan" nằm trong tab "Thống kê nhanh". Sau 2 vòng điều chỉnh theo phản hồi thực tế, chốt lại:
- Màn Danh sách công nợ chỉ còn 3 cột thuần hiển thị (Khách hàng, SĐT, Tổng nợ), không có cột "Thao tác" nào.
- Hai nút "Thu nợ" / "Ghi nợ tay" đặt **chung một lần** ở đầu màn Danh sách công nợ — bấm vào mở modal có ô tìm khách hàng (dùng lại `/customers/scan`, cùng cơ chế select2 với `#customerKey` ở POS) rồi mới nhập số tiền/ghi chú. Tránh lặp lại 2 nút trên từng dòng khi danh sách dài.
- **"Danh sách đơn hàng liên quan" không nằm ở trang riêng, mà là một tab mới "Công nợ"** trên chính trang chi tiết khách hàng (`customer-statis.blade.php`), cạnh các tab có sẵn "Thông tin cơ bản" / "Thống kê nhanh" / "Đơn hàng đã mua". Tab này cũng có 2 nút Thu nợ/Ghi nợ tay riêng cho khách hàng đó (đã biết trước khách hàng nên không cần picker). Lý do không tách trang riêng: dữ liệu và UI của "xem chi tiết nợ 1 khách hàng" giống hệt nhau dù vào từ đâu — tách 2 nơi hiển thị cùng 1 thứ chỉ tạo trùng lặp code/khó bảo trì mà không có lợi ích rõ ràng.
- Click vào một dòng ở màn Danh sách công nợ → điều hướng thẳng tới `route('customers.statis', $id) . '#debtTab'`. Trang khách hàng có đoạn JS nhỏ: nếu `location.hash === '#debtTab'` lúc load trang thì tự kích hoạt tab đó bằng Bootstrap tab API (`$('a[href="#debtTab"]').tab('show')`), thay vì phải bấm tab thủ công sau khi trang tải xong.
- Tab "Thống kê nhanh" giữ nguyên cột tóm tắt "Tổng nợ hiện tại" trong bảng thống kê (xem nhanh không cần đổi tab), nhưng KHÔNG còn bảng đơn hàng liên quan — bảng đó chỉ còn ở tab "Công nợ" duy nhất, tránh trùng lặp.

## Risks / Trade-offs

- **[Risk — đã xử lý]** Xoá một đơn hàng (`OrderController@destroy`) đang có `pos_debt` entry liên kết sẽ để lại nợ "mồ côi" — khách vẫn nợ nhưng đơn hàng gốc không còn (FK `customer_debts.order_id` là `onDelete('set null')`, không cascade xoá entry). → Mitigation: xem decision #6 bên dưới — `CustomerDebt::voidForOrder()` được gọi trong `OrderController@destroy` trước khi xoá đơn, và FE hiện rõ số nợ sẽ bị huỷ trong hộp thoại xác nhận xoá.
- **[Risk — đã xử lý]** Điều chỉnh `pos_debt` entry theo phần chênh lệch khi sửa đơn `done` có thể đẩy `debt_total` sai lệch/âm nếu khách đã được thu nợ ở giữa. → Mitigation: bỏ hẳn khả năng sửa `debt_amount` sau khi đơn đã `done` (decision #3); phương thức `CustomerDebt::adjustForOrder()` đã bị xoá khỏi codebase, chỉ còn `CustomerDebt::record()` cho lần ghi `pos_debt` đầu tiên.
- **[Trade-off]** Cache `debt_total` có thể lệch khỏi ledger nếu có sửa dữ liệu trực tiếp trong DB (ngoài luồng ứng dụng). → Chấp nhận được vì ledger vẫn giữ đủ dữ liệu để tính lại bằng `SUM(pos_debt + manual_debt) - SUM(repayment)` nếu cần đối soát sau này (không cần xây công cụ đối soát ngay trong phạm vi này).

## Migration Plan

1. Migration tạo bảng `customer_debts` (không backfill — chưa có dữ liệu nợ trước đó).
2. Migration thêm cột `customers.debt_total` (`decimal`, default `0`, không backfill vì mọi khách hàng đều bắt đầu từ 0 nợ).
3. Migration thêm cột `orders.debt_amount` (`decimal`, default `0`, không backfill — các đơn cũ trước tính năng này coi như không có nợ).
4. Thay đổi hoàn toàn cộng thêm (additive), không đụng bảng/cột hiện có nào khác → không cần kế hoạch rollback đặc biệt ngoài `down()` chuẩn của migration.

## Open Questions

- Ghi nợ tay và thu nợ có cần giới hạn theo role/permission nào không, hay mọi tài khoản admin hiện có đều được phép? (Giả định hiện tại: không giới hạn thêm.)
