# 05 — Domain: Tài chính & Hoá đơn

> Domain xử lý hoá đơn khách hàng, nhắc nợ, đối soát thanh toán/trả trước, hoá đơn hoa hồng đại lý (kickback), credit note (hoàn tiền/điều chỉnh), và mã giảm giá.

## 1. Sơ đồ quan hệ tổng quan

```
Invoice (hoá đơn khách hàng)
 ├─ 1-n  InvoiceFile        (PDF đã in)
 ├─ (ngoài ORM) ← Order/BulkOrder/Credit  (nguồn tạo hoá đơn, qua orderIds serialize)
 ├─ (ngoài ORM) → InvoiceJournal  (gộp nhiều hoá đơn thành 1 lô kế toán)
 └─ (ngoài ORM) → InvoiceReminder (thư nhắc nợ phát sinh từ hoá đơn này)

InvoiceJournal → gộp nhiều Invoice (invoiceIds serialize)
InvoiceReminder → 1-n InvoiceReminderFile

DownPaymentFile (file giao dịch ngân hàng/Svea import về)
 └─ 1-n DownPaymentRow (từng dòng giao dịch)
      └─ áp dụng vào → Order hoặc Invoice → sinh ra → DownPayment (bản ghi đối soát)
 (nhiều DownPaymentFile đã đối soát xong) → gộp vào → DownPaymentFileJournal
                                                        └─ 1-n DownPaymentFileJournalFile

KickbackInvoice (hoá đơn hoa hồng trả cho seller/đại lý)
 ├─ 1-n KickbackInvoiceItem (dòng hoa hồng — từ đơn hàng thật hoặc dòng tự nhập)
 └─ 1-n KickbackInvoiceFile

Credit (credit note / điều chỉnh)
 ├─ 1-n CreditItem
 └─ (ngoài ORM) → Invoice  (khi được "xuất hoá đơn hoá" thành 1 Invoice âm)

CouponCampaign (chương trình khuyến mãi)
 └─ 1-n CouponCode (từng mã cụ thể)
```

**Quy ước xuyên suốt domain:** hầu như mọi entity đều **snapshot** thông tin khách hàng/kênh/người bán tại thời điểm tạo (không join sống), và dùng chung `AutoCountService` để sinh số chứng từ. Danh sách "gom nhóm" (đơn thuộc hoá đơn, hoá đơn thuộc journal...) đều lưu bằng `serialize()` một mảng ID trong cột `text`.

⚠️ **Điểm không nhất quán cần lưu ý:** phần lớn entity trong domain này xoá mềm (`dateDeleted`), nhưng `CreditService::delete()`, `CouponCampaignService::delete()`, `CouponCodeService::delete()` đều gọi `$em->remove()` — **xoá cứng thật sự** dù entity có sẵn cột `dateDeleted`.

---

## 2. Invoice — Hoá đơn khách hàng

**Bảng:** `invoices` | Chứng từ thanh toán chính, có thể tạo từ: 1 đơn lẻ (`individual`), gộp nhiều đơn theo tháng (`monthly`), từ 1 `BulkOrder` (`bulk_order`), hoặc từ 1 `Credit` (`credit` — hoá đơn âm/hoàn tiền). Loại nguồn lưu ở `createdFrom`.

### Trường quan trọng

| Nhóm | Trường | Ghi chú |
|---|---|---|
| Đơn gộp | `orderIds` (serialize) | Danh sách đơn hàng gộp vào hoá đơn này |
| Số & mã | `invoiceCountNr`, `invoiceNr` | Hoá đơn tạo từ Credit có tiền tố `K{n}` |
| Thanh toán | `totalSumInclTaxPaid`, `paymentStatus/Id`, `datePaidFull` | |
| Hạn thanh toán | `invoiceDueDate` | Tính tự động — xem công thức bên dưới |
| Xuất khẩu | `foreignerInvoice`, `euInsideOutside` | Nếu `foreignerInvoice=true`, **thuế bị loại bỏ hoàn toàn** khỏi tổng tiền |
| Nhắc nợ | `reminderCount`, `lastReminderId/Nr/Date` | |

### Công thức tính hạn thanh toán (`updateInvoiceDueDate`)

Dựa theo chuỗi `paymentType`: `"45 dagar netto"` / `"30 dagar netto"` / `"20 dagar netto"` / `"10 dagar netto"` (tiếng Thuỵ Điển: "X ngày net") → cộng tương ứng 45/30/20/10 ngày kể từ `dateInvoice`. **Mọi điều khoản thanh toán khác** mặc định **+3 ngày**.

### Logic nghiệp vụ đáng chú ý (`InvoiceService`)

- **`postUpdate()`**: mỗi khi hoá đơn được tạo/sửa, hệ thống **gỡ liên kết cũ rồi gán lại toàn bộ** danh sách đơn hàng theo `orderIds` hiện tại, cộng dồn `totalTax`/`totalSumExclTax`/`totalSumInclTax`/`shippingFee` từ các đơn. Với hoá đơn từ `BulkOrder`, cộng thêm phí ship/thuế của chính `BulkOrder`.
- **`addFromCredit()`**: sinh hoá đơn với **số tiền âm** (bằng đúng giá trị âm của Credit gốc), dùng dãy số riêng (`AutoCount::NAME_INVOICE_CREDIT`), số hoá đơn có tiền tố `K`.
- **`addFromMultiOrders()` / `addFromMultiOrdersIndividual()`**: tạo hàng loạt — gộp theo kênh (monthly/bulk) hoặc theo từng khách hàng riêng (individual, có tuỳ chọn tách 1 hoá đơn/1 đơn).
- **`exportPdfNotPaid1200()`**: báo cáo "Reskontralista" (công nợ chưa thu) tại 1 ngày chốt — tính số tiền đã trả qua tra `DownPaymentRow`, làm tròn xuống bội số 0.5.
- **`revert()`**: đảo ngược hoá đơn (xoá mềm + gỡ liên kết đơn hàng khỏi hoá đơn).

### Ví dụ tình huống

> **Xuất hoá đơn gộp cuối tháng:** Cuối tháng, kế toán chọn tất cả đơn "complete" chưa xuất hoá đơn của khách "ACME Corp" (điều khoản `invoiceOncePerMonth=true`), gọi `POST /invoices/create-from-multi-orders`. Hệ thống gom theo khách hàng, tạo **1 `Invoice`** duy nhất, `invoiceDueDate` tự tính +30 ngày (nếu điều khoản là "30 dagar netto"). PDF được sinh và gửi email tự động.
>
> **Xuất hoá đơn xuất khẩu (miễn thuế):** Khách hàng ở Na Uy (ngoài EU) đặt hàng, `Customer` được đánh dấu `foreignerInvoice=true`. Khi tạo `Invoice`, `totalTax` tự động bị đưa về 0 và trừ khỏi `totalSumInclTax` — hoá đơn xuất ra không có VAT.

---

## 3. Invoice — các entity phụ trợ

### 3.1. InvoiceFile / InvoiceJournal / InvoiceReminder / InvoiceReminderFile

| Entity | Bảng | Vai trò |
|---|---|---|
| `InvoiceFile` | `invoice_files` | Lưu từng bản PDF đã in cho hoá đơn (hỗ trợ in lại/lịch sử) |
| `InvoiceJournal` | `invoice_journal` | Gộp nhiều hoá đơn thành 1 "lô" phục vụ xuất báo cáo kế toán định kỳ — logic đồng bộ giống `Invoice.postUpdate()`: chỉ nhận các hoá đơn **chưa thuộc journal nào khác** |
| `InvoiceReminder` | `invoice_reminder` | Thư nhắc nợ — snapshot thông tin khách hàng/hoá đơn tại thời điểm gửi nhắc; khi tạo, tự động cập nhật ngược `lastReminderDate/Id` + tăng `reminderCount` trên `Invoice` gốc |
| `InvoiceReminderFile` | `invoice_reminder_files` | PDF thư nhắc nợ đã gửi |

`InvoiceReminderService::addFromMultiInvoices()` chỉ chọn các hoá đơn **thoả điều kiện** `reminderList` (chưa thanh toán đủ + quá hạn + không phải loại `monthly`) — tránh nhắc nhầm hoá đơn chưa tới hạn.

### Ví dụ tình huống

> Cronjob/kế toán chọn "Gửi nhắc nợ hàng loạt" cho tất cả hoá đơn quá hạn. Hệ thống tự lọc ra các hoá đơn thực sự đủ điều kiện (`reminderList=yes`), với mỗi hoá đơn tạo 1 `InvoiceReminder`, gửi PDF qua `MailService::invoiceReminder()`, đồng thời hoá đơn gốc được cập nhật `reminderCount += 1`.

---

## 4. Down Payment — Đối soát thanh toán / trả trước

Đây là engine đối soát giao dịch ngân hàng/thanh toán với đơn hàng hoặc hoá đơn.

### 4.1. DownPaymentFile

**Bảng:** `down_payment_files` | Một lô file giao dịch được nhập vào — loại `order` (file CSV ngân hàng, import tự động qua SFTP) hoặc `invoice` (nhập tay 1 loạt thanh toán theo số hoá đơn). Theo dõi `rows`/`rowsUsed`/`rowsNotUsed` để biết đã đối soát hết chưa.

### 4.2. DownPaymentRow

**Bảng:** `down_payment_rows` | Từng dòng giao dịch **chưa áp dụng** — "giao dịch chờ khớp" với 1 đơn hàng/hoá đơn theo `orderNr`. Trạng thái `STATUS_NOT_USED` → `STATUS_USED` sau khi áp dụng.

### 4.3. DownPayment

**Bảng:** `down_payments` | Bản ghi **kết quả** sau khi áp dụng 1 `DownPaymentRow` vào 1 `Order`/`Invoice` — lưu vết audit: số tiền đã áp, số dư còn lại tại thời điểm đó. Chỉ được tạo tự động qua `DownPaymentRowService::applyMulti()`, không có CRUD thủ công riêng.

### Luồng đối soát (`DownPaymentRowService::applyMulti`)

1. Lấy các `DownPaymentRow` chưa dùng theo ID được chọn.
2. Nếu `type == invoice` → khớp với `Invoice` theo `invoiceNr`; ngược lại khớp với `Order` theo `orderNr`.
3. Tính `đã trả mới = đã trả cũ + paymentAmount`, `còn lại = tổng tiền - đã trả mới`.
4. Ghi 1 `DownPayment` (audit), cập nhật `paymentStatus` trên `Order`/`Invoice` (`unpaid`/`partly_paid`/`paid` dựa theo số tiền còn lại).
5. Đánh dấu `DownPaymentRow.status = USED`.

`confirmApplyMulti()`/`confirmApplyMultiForInvoice()` cho phép **xem trước** kết quả (không ghi DB) — dùng cho bước xác nhận trên UI trước khi áp dụng thật.

### Nhập file tự động qua SFTP (`importDownPaymentGetFileFTP`)

Kết nối SFTP tới máy chủ Svea (`sveasftp`), tải file CSV mới, bỏ qua file đã tồn tại (chống trùng theo `fileName`), parse và tạo `DownPaymentFile` + `DownPaymentRow`, sau đó **di chuyển file gốc trên server vào thư mục `arkiv`** (đã xử lý). Chi tiết cronjob liên quan xem file 10.

### 4.4. DownPaymentFileJournal / DownPaymentFileJournalFile

**Bảng:** `down_payment_files_journal` | Gộp nhiều `DownPaymentFile` **đã đối soát xong hoàn toàn** (`rowsNotUsed == 0`) thành 1 báo cáo PDF tổng hợp. **Ràng buộc đặc biệt:** tất cả file được gộp phải **cùng ngày `dateImported`** — nếu không sẽ bị từ chối với lỗi "All files must have the same dateImported".

### Ví dụ tình huống

> Ngân hàng gửi file CSV giao dịch ngày 15/07, cronjob tự động tải về qua SFTP lúc 3h sáng, tạo 1 `DownPaymentFile` với 42 `DownPaymentRow`. Kế toán vào hệ thống, chọn 40 dòng khớp đúng với số hoá đơn tồn tại, bấm "Áp dụng" → 40 `DownPayment` được tạo, 40 hoá đơn tương ứng cập nhật trạng thái thanh toán. 2 dòng còn lại không khớp được số đơn nào (có thể ghi sai nội dung chuyển khoản) — vẫn ở trạng thái `NOT_USED`, kế toán xử lý thủ công sau. Cuối ngày, chỉ khi **toàn bộ** file trong ngày đã đối soát hết, mới gộp được vào `DownPaymentFileJournal`.

---

## 5. KickbackInvoice — Hoá đơn hoa hồng đại lý

**Bảng:** `kickback_invoices` (+ `kickback_invoice_items`, `kickback_invoice_files`) | Hoá đơn **hoa hồng bán hàng** trả định kỳ (thường theo tháng) cho seller/đại lý dựa trên doanh số các đơn hàng đã hoàn tất trong kỳ — về bản chất là hoá đơn "tự lập" (bên mua = 1 khách hàng nội bộ đại diện hệ thống, bên bán = đại lý nhận hoa hồng).

### Trường quan trọng

- `invoiceCountNr`: dùng dãy số riêng, **bắt đầu từ 70000** (không trùng dải số với `Invoice` thường).
- `invoiceDueDate`: cố định **+20 ngày** kể từ `dateInvoice` — **khác** với `Invoice` (không phụ thuộc điều khoản thanh toán).
- `kickbackInvoicePeriodInterval`/`kickbackDateFrom/To`: kỳ báo cáo (1 tháng dương lịch, năm hiện tại).
- `kickbackInvoiceSeparateByBrand`: nếu bật, **mỗi brand ra 1 hoá đơn riêng** thay vì gộp chung cho cả kênh.

### Luồng tạo (`KickbackInvoiceService::create`)

1. Kiểm tra `Channel.kickbackInvoiceActive = true`.
2. Xác định kỳ báo cáo = tháng theo `Channel.kickbackInvoicePeriodInterval` của **năm hiện tại**.
3. Gọi `OrderService::reportOrderProduct()` lọc đơn "complete" trong kênh/kỳ (lọc theo `change_status_date`).
4. Nếu có dữ liệu, tạo 1 hoặc nhiều hoá đơn (tuỳ `kickbackInvoiceSeparateByBrand`), mỗi dòng `KickbackInvoiceItem` tương ứng 1 dòng doanh số, hỗ trợ thêm dòng tự nhập (`itemType = free_text`, số lượng ép về 1).

### Ví dụ tình huống

> Kênh "Reseller Network" có `kickbackInvoiceActive=true`, `kickbackInvoicePeriodInterval=6` (tháng 6), `kickbackInvoiceSeparateByBrand=true`. Cronjob `kickbackInvoice:create` chạy đầu tháng 7, gom toàn bộ đơn "complete" trong tháng 6 của kênh này, **tách theo từng brand** (VD "Brand X" và "Brand Y" ra 2 hoá đơn riêng), mỗi hoá đơn tính tổng hoa hồng = tổng `priceKickback` của các dòng sản phẩm thuộc brand đó, cộng thuế theo `tax_default`. Hạn thanh toán tự động là ngày lập hoá đơn + 20 ngày.

---

## 6. Credit — Credit Note (Hoàn tiền / Điều chỉnh)

**Bảng:** `credits` (+ `credits_items`) | Ghi nhận việc hoàn tiền toàn phần/một phần hoặc điều chỉnh giảm trừ cho 1 `Order` đã hoàn tất (hoặc 1 `Invoice`). Có thể sau đó được "xuất hoá đơn hoá" thành 1 `Invoice` âm qua `createInvoice()`.

### Điểm đặc biệt cần lưu ý

- **Đánh số** theo dạng `K0{n}-{orderNr}` — số thứ tự **n riêng theo từng đơn hàng gốc** (đơn #1024 có thể có nhiều credit K01-1024, K02-1024...), không dùng chung 1 dãy số toàn hệ thống.
- **Credit tạo từ Invoice** (`addForInvoice`) mặc định được **xoá mềm ngay khi tạo** — nghĩa là ở trạng thái "nháp/chưa kích hoạt", cần gọi `forceCreate()` để kích hoạt lại trước khi sử dụng.
- `openCredit`: nếu `true`, cho phép thêm/sửa dòng sản phẩm tự do (không giới hạn theo đơn gốc); nếu `false`, các dòng cố định từ lúc tạo.
- `totalSum = shippingFee - discount + Σ(giá × SL)`.
- ⚠️ `delete()` là **xoá cứng** (khác quy ước chung — xem đầu file).

### Ví dụ tình huống

> Khách trả lại 1 sản phẩm lỗi từ đơn #1024 (đã hoàn tất, đã xuất hoá đơn). Nhân viên CSKH tạo `Credit` từ đơn gốc (`createFromOrder`), số credit tự động thành `K01-1024`. Vì đơn gốc **đã** xuất hoá đơn, credit này cần được "hoá đơn hoá" để trừ vào công nợ khách — gọi `createInvoice($creditId)` → `InvoiceService::addFromCredit()` tạo 1 `Invoice` mới có **tổng tiền âm** đúng bằng giá trị credit, số hoá đơn `K{n}`, khách hàng nhìn thấy khoản này trừ thẳng vào công nợ hiện tại.

---

## 7. CouponCampaign & CouponCode — Mã giảm giá

### 7.1. CouponCampaign

**Bảng:** `coupon_campaign` | Định nghĩa **luật** của 1 chương trình khuyến mãi: loại (`discount` giảm giá hoặc `invoice` mua trước trả sau), phạm vi áp dụng (`assortmentActiveOnAllProduct` hoặc giới hạn theo danh sách sản phẩm/danh mục cụ thể — lưu dạng serialize), mức giảm (`discountPercent` hoặc `discountAmountInclTax` cố định — ưu tiên số tiền cố định nếu có), thời gian hiệu lực (`dateStart`/`dateEnd`), và `removeKickBackOnUsedOrder` (loại đơn dùng coupon này khỏi tính kickback).

### 7.2. CouponCode

**Bảng:** `coupon_code` | Từng mã cụ thể thuộc 1 campaign, unique theo `(name, campaignId)`. Hỗ trợ **tự sinh hàng loạt** mã ngẫu nhiên 5 ký tự (loại bỏ ký tự dễ nhầm như `O`/`I`/`0`/`1`).

### Engine kiểm tra & tính giảm giá (`CouponCodeService`) — dùng ngay tại bước checkout webshop

1. `validateCouponCode()`: mã tồn tại, đúng kênh, campaign đang active, đúng kênh, còn hạn (`dateStart`≤now≤`dateEnd`), và **nếu campaign không cho dùng nhiều lần** (`canUseMultiTimes=false`) thì mã phải chưa từng được dùng (`status = new`).
2. `validateCodeTypeDiscount()`: nếu có `discountAmountInclTax` cố định → trả thẳng số tiền đó; ngược lại tính subtotal **của các sản phẩm đủ điều kiện** (toàn giỏ hoặc chỉ sản phẩm/danh mục được cấu hình) rồi nhân `discountPercent`.
3. `validateCodeTypeInvoice()`: chỉ kiểm tra **mọi sản phẩm trong giỏ** có thuộc phạm vi cho phép hay không (không tính số tiền — loại coupon này đổi **điều khoản thanh toán**, không giảm giá).

### Ví dụ tình huống

> Chương trình "SUMMER10" giảm 10% cho riêng danh mục "Áo phông mùa hè", tối đa dùng 1 lần/mã, 500 mã được tự sinh hàng loạt cho chiến dịch email marketing. Khách nhập mã tại bước checkout (`POST /kassa/check-coupon`) → hệ thống kiểm tra còn hạn, mã chưa dùng, sau đó chỉ tính giảm giá trên **các sản phẩm thuộc danh mục "Áo phông mùa hè"** trong giỏ (sản phẩm khác trong giỏ không được giảm). Sau khi đơn hàng hoàn tất thanh toán, mã chuyển trạng thái `used`, không thể dùng lại lần 2.

---

## 8. Ví dụ tình huống tổng hợp — dòng chảy tài chính trọn vẹn 1 tháng

> 1. Trong tháng, các đơn hàng của khách "ACME Corp" lần lượt hoàn tất sản xuất và giao hàng (`orderStatus = complete`).
> 2. Cuối tháng, kế toán gộp toàn bộ đơn "complete" chưa xuất hoá đơn của ACME thành **1 `Invoice`** (điều khoản "30 dagar netto" → hạn thanh toán +30 ngày).
> 3. Nhiều hoá đơn của các khách khác trong ngày được gộp vào **1 `InvoiceJournal`** để xuất báo cáo kế toán định kỳ.
> 4. 25 ngày sau, hoá đơn của ACME vẫn chưa thanh toán và đã quá hạn → hệ thống liệt kê vào danh sách nhắc nợ (`reminderList`), kế toán gửi **1 `InvoiceReminder`**.
> 5. 3 ngày sau, ACME chuyển khoản thanh toán. File CSV ngân hàng được cronjob tải tự động, kế toán đối soát dòng giao dịch khớp với số hoá đơn ACME qua `DownPaymentRow.applyMulti` → `Invoice.paymentStatus = paid`.
> 6. Song song, vì "ACME Corp" đồng thời là 1 kênh bán có bật kickback, cronjob đầu tháng sau tạo **1 `KickbackInvoice`** trả hoa hồng cho seller dựa trên doanh số các đơn "complete" của kênh này trong tháng vừa qua.
> 7. Một trong các đơn của ACME bị khách khiếu nại lỗi sản phẩm → tạo **1 `Credit`**, sau đó xuất hoá đơn hoá thành 1 `Invoice` âm để trừ vào công nợ kỳ sau.

Mỗi bước trên tương ứng với 1 mục chi tiết ở các phần trên của file này.
