# TSHIRTORDER-1634 — Prepaid: changes and more functions

## Yêu cầu gốc

> TSHIRTORDER-1634 prepaid - changes and more functions

Kèm 7 ảnh mockup (xem `files/`). Chú thích trên từng ảnh (nguyên văn):

| Ảnh | Màn hình | Chú thích trên mockup |
|---|---|---|
| `files/01-channel-prepaid-tab.jpg` | Kênh #93 → *Redigera kanal* → tab **Prepaid** | "Add prepaid on. / off" · "Add payment ID , count up so we see each transaction" · "005" · "Status = on / off" |
| `files/02-order-create.jpg` | Orders → *Skapa ny* | "If select channel prepaid = on show sum" |
| `files/03-order-detail-betaltyp.jpg` | Order #63438 → chi tiết | "If created on channel prepaid = on change status on betaltyp to prepaid" |
| `files/04-order-detail-sum.jpg` | Order #63438 → chi tiết | "And show sum prepaid here" |
| `files/05-invoice-detail.jpg` | Faktura #121 → chi tiết | "Add prepaid sum - here If its activated" · "Prepaid = 10 kr" |
| `files/06-order-list-att-fakturera.jpg` | Orders → *Att fakturera löpande* | "Add column prepaid sum if channel is turn on" |
| `files/07-invoice-list.jpg` | Ekonomi → *Fakturor* | "Invoice list show if channel is turn on prepaid" |

---

## Tổng quan

Mở rộng tính năng prepaid của kênh (đã có từ trước, log đơn bị trừ vừa làm ở TSHIRTORDER-1633):
- thêm **công tắc bật/tắt** prepaid theo kênh
- đánh **số giao dịch** tăng dần cho từng lần nạp
- order tạo trên kênh bật prepaid thì **tự gán Betalningstyp = Prepaid**
- **hiện số tiền prepaid** ở 5 màn hình: tạo order, chi tiết order, chi tiết hoá đơn, danh sách *Att fakturera löpande*, danh sách hoá đơn

Phạm vi: **Backend (API)** + **Frontend React** (repo FE riêng).

> ⚠️ **Phát hiện quan trọng: phần nền prepaid đã có sẵn, nhưng chưa có cờ on/off và chưa gắn với Betalningstyp**
>
> | Thành phần | File | Trạng thái |
> |---|---|---|
> | Field `prepaidSum`, `prepaidWatcherLevel`, `prepaidDate`, `prepaidCurrentSum`, `guestLoginShowPrepaid` | `src/Entity/Channel.php:317-344` | ✅ Có |
> | Lịch sử nạp `ChannelPrepaidLog` (bảng `channels_prepaid_logs`) | `src/Entity/ChannelPrepaidLog.php` | ✅ Có — nhưng **chưa có số giao dịch / status** |
> | Tính số dư + gửi email ngưỡng (nút *Skicka*) | `OrderService::getChannelPrepaidCurrentSum()` — `src/Service/OrderService.php:5507` | ✅ Có |
> | Log đơn bị trừ `GET /channels/{id}/prepaid-orders` (TSHIRTORDER-1633) | `OrderService::getChannelPrepaidOrders()` — `src/Service/OrderService.php:5598` | ✅ Có |
> | Điều kiện đơn bị trừ (dùng chung cho SUM và list) | `OrderRepository::applyPrepaidOrderCriteria()` — `src/Repository/OrderRepository.php:502` | ✅ Có — **không xét Betalningstyp** |
> | Betalningstyp của order (`paymentType`, `paymentTypeId` → `StatusList` type `order_payment_type`) | `src/Entity/Order.php:309-312`, gán ở `OrderService.php:518` | ✅ Có |
> | Default Betalningstyp khi tạo order (`Customer.defaultPaymentTypeId` → `Channel.defaultPaymentTypeId`) | `src/Service/OrderService.php:1184` | ✅ Có |
> | **Cờ bật/tắt prepaid trên Channel** | — | ❌ Chưa có — hiện tại kênh có `prepaidDate` là coi như đang dùng |
> | **Betalningstyp "Prepaid"** trong `StatusList` | — | ❓ Chưa xác minh trên DB (ảnh 03 đang hiện *Förskott*) |
> | Hiện prepaid ở order / invoice / 2 danh sách | — | ❌ Chưa có |

---

## Bối cảnh: prepaid đang hoạt động thế nào

Chi tiết đầy đủ + ví dụ số liệu: xem `docs/issues/TSHIRTORDER-1633-prepaid-transaction-log/prepaid-transaction-log.md`. Tóm tắt những điểm ảnh hưởng tới ticket này:

```
tillgänglig summa = channel.prepaidSum − SUM(order.totalSum + order.totalTax)
```

Đơn bị trừ = đơn của kênh, chưa xoá, chưa `cancel`, `dateOrder > <ngày prepaidDate> 23:59:59`. **Mọi đơn** thoả điều kiện đều bị trừ, bất kể Betalningstyp, bất kể đã xuất hoá đơn hay đã được khách trả qua inbetalning chưa.

1. Nút **Skicka** **ghi đè** `prepaidSum` (không cộng dồn) và **mỗi lần bấm** INSERT 1 dòng `ChannelPrepaidLog`, kể cả khi không đổi gì.
2. Email cảnh báo ngưỡng **chỉ gửi khi bấm Skicka** — không có cronjob, không có trigger khi tạo order.
3. `ReturnOrder` / hoá đơn kredit **không** cộng lại vào số dư.
4. "Totalt betalt / Kvar att betala" của hoá đơn tính từ `DownPaymentRow` (inbetalning import từ file ngân hàng) — `src/Service/InvoiceService.php:~400`. Prepaid hiện **không liên quan** gì tới phần này.

---

## Chi tiết theo mockup

### 1. Tab Prepaid của kênh (`files/01-channel-prepaid-tab.jpg`)

- Thêm **toggle Prepaid on/off** cho kênh.
- Bảng lịch sử nạp (bên phải) thêm cột **Payment ID** tăng dần — mockup ghi "005" cạnh dòng lịch sử → hiểu là số thứ tự giao dịch nạp (001, 002, …, 005).
- Thêm **Status = on/off** — vị trí trên mockup nằm ngay dưới "005", không rõ là status của từng dòng giao dịch hay của kênh (xem Q10).

### 2. Tạo order (`files/02-order-create.jpg`)

Khi chọn **Kanal** đang bật prepaid → hiện số tiền prepaid ở ô trống phía trên bảng hoá đơn (ngang hàng "Kvar att betala = 0.00"). Hợp lý nhất là **số dư hiện tại** của kênh.

### 3. Chi tiết order — Betalningstyp (`files/03-order-detail-betaltyp.jpg`)

Order tạo trên kênh bật prepaid → Betalningstyp tự đặt thành **Prepaid** (mockup hiện đang là *Förskott*).

### 4. Chi tiết order — số prepaid (`files/04-order-detail-sum.jpg`)

Hiện số prepaid ở ô góc phải trên, ngang hàng "Skapad av / Skapad datum". Chưa rõ là số dư kênh hay số tiền của order này bị trừ (Q6).

### 5. Chi tiết hoá đơn (`files/05-invoice-detail.jpg`)

Trong thanh *Betalningsstatus / Totalt betalt / Kvar att betala* thêm ô **Prepaid = 10 kr**, chỉ khi kênh bật prepaid. Chưa rõ có trừ vào *Kvar att betala* và đổi *Betalningsstatus* không (Q4).

### 6. Danh sách *Att fakturera löpande* (`files/06-order-list-att-fakturera.jpg`)

URL `orders?orderStatusId=22&invoiceIndividual=yes`. Thêm cột **prepaid** giữa *Bulkorder* và *Åtgärder*, chỉ có giá trị khi kênh của dòng bật prepaid.

### 7. Danh sách hoá đơn (`files/07-invoice-list.jpg`)

URL `invoices?not_invoiceJournal_id=yes`. Thêm cột **prepaid** giữa *Betalningsstatus* và *Kvar att betala*, chỉ có giá trị khi kênh bật prepaid.

---

## Questions gửi sếp

> Câu hỏi viết bằng tiếng Anh để gửi thẳng cho sếp. Cột **Answer** để trống — điền khi có trả lời, rồi cập nhật lại phần Thiết kế kỹ thuật + TODO bên dưới.
> **Q1, Q4, Q6 quyết định gần như toàn bộ thiết kế** — cần trả lời trước.

### A. Money logic (most important)

| # | Question | Options | Answer |
|---|----------|---------|--------|
| 1 | **Which orders are deducted from the prepaid sum?** Today **every** order of the channel placed after the prepaid date is deducted, no matter the payment type (Betalningstyp). After this ticket, should only orders with Betalningstyp = **Prepaid** be deducted? If an admin manually changes an order's Betalningstyp to something else, is it still deducted? | a) Keep as today: every order after the prepaid date; Betalningstyp is only a label · b) Only orders with Betalningstyp = Prepaid. Note: this changes the current available sum of channels already using prepaid | **(a)** Keep as today — every order after the prepaid date is deducted, Betalningstyp is only a label. **New requirement:** once an order's Betalningstyp is Prepaid, admin must **not be able to change it**. *(sếp: "every order from the start date added prepaid is deducted, betalnings typ is just a flag and should not be able to change if prepaid is used")* |
| 2 | **What does prepaid = OFF mean?** | a) Only hides prepaid info on screens, orders are still deducted · b) Stops deducting. When it is turned ON again, are orders created while it was OFF deducted or not? | **(b)** Stops deducting entirely. When OFF: don't deduct, don't flag the order as Prepaid, don't show prepaid sum anywhere (order, invoice, lists). *(sếp: "if prepaid is off we don't use it - and don't flag order , don't show prepaid sum etc in order , invoice")* |
| 3 | **What happens when the prepaid sum runs out** (available sum is 0 or lower than the new order)? | a) Allow the order, the available sum goes negative · b) Block creating the order · c) Create the order with the normal payment type (not Prepaid) | **(a)**-like: order is **not blocked**, sum can go negative ("it's a countdown"). As a warning: turn the sum **RED** and show a **popup every time** a new order is created while the balance is below the threshold (confirmed 2026-09-29), plus the daily admin e-mail (Q12). *(sếp: "its count down - we change color to RED and maybe add popup each time new order is created ( or send email to admin )")* |
| 4 | **Are prepaid orders still invoiced, and does prepaid reduce "Kvar att betala"?** If the order is still invoiced and "Kvar att betala" stays the same, the customer is asked to pay twice (once by prepaid, once by the invoice). | a) Still invoice; the prepaid amount is deducted from "Kvar att betala" and the invoice becomes "Betald" automatically · b) Only show the prepaid amount as information, it does not change the invoice · c) Do not invoice prepaid orders | **(b)+**: nothing is auto-calculated/deducted on the order or invoice, prepaid stays purely informational. Instead, admin needs a **manual "add downpayment prepaid" action** on the order. *(sếp: "no prepaid is calculated on order , and in order we needs to manually add downpayment option prepaid")* — exact UI flow unclear, see follow-up below |
| 5 | Is **"Förskott"** the same as Prepaid, or should we create a new Betalningstyp called **"Prepaid"**? (Förskott normally means paying in advance for one order, which is different from a prepaid balance.) | a) Use Förskott · b) Create a new type "Prepaid" (our suggestion) | **(a)** Förskott is the same as Prepaid — reuse it, no new Betalningstyp. *(sếp: "förskott is same as prepaid")* |

### B. Which amount to show

| # | Question | Options | Answer |
|---|----------|---------|--------|
| 6 | The "prepaid sum" on **order detail, invoice detail, the "Att fakturera löpande" list and the invoice list**: is it the **remaining sum of the channel**, or **the amount of this order/invoice that was deducted from prepaid**? | Our suggestion: create order screen = remaining sum of the channel; order detail / invoice detail / both list columns = amount deducted for that row (if it is the remaining sum, the whole column shows the same number on every row of the same channel). Order detail could show both. | **Remaining sum of the channel, everywhere** — `prepaidSum − orders (+ credits added back)`. Same number repeats on every row/screen of the same channel. *(sếp: "the sum is the remaining sum , so its mean the added prepaid sum minus orders")* |
| 7 | An invoice with **several orders** (bulk order / monthly invoice): is its prepaid amount the total of all its orders? Should a **credit invoice / return order** give money back to the prepaid sum? (Today returns are not added back.) | a) Total of the orders; credits are not added back · b) Total of the orders; credits are added back to the prepaid sum | **(b)**: total of all orders; **credit/return orders DO add the sum back** to the channel's prepaid balance (reverses current behaviour). *(sếp: "yes we remove all order sum from prepaid same as if we add kredit order we add back the sum")* |
| 8 | Orders/invoices created **before** prepaid was turned on or before the prepaid date: show the prepaid column empty or 0? | Our suggestion: empty | Not included — but must be **shown explicitly as excluded**, not just an empty cell. *(sếp: "if orders is created before we show its not included and show that")* |

### C. Prepaid tab of the channel

| # | Question | Options | Answer |
|---|----------|---------|--------|
| 9 | **"Payment ID, count up"**: is it a number created by the system (001, 002, …) or a reference the admin types in (e.g. bank transfer reference)? Note: today **every click on "Skicka"** adds a row to the history, even when nothing changes (it is also used to recalculate). An automatic number would count up even when no money came in. | a) Automatic number per channel, only when money is really added (we then separate "add money" from "recalculate") · b) Typed in by the admin | **(b)**: a **reference the admin types in**, to make it simple to find the matching bank payment and confirm it's all OK — needed because repeat orders can have the exact same sum. *(sếp: "payment id is to make it more simple for admin to find payment and see if its all oki , as the sum can easily be the same on repeat order")* |
| 10 | **"Status = on/off"** on the mockup: is it the status of **each transaction** (turn one payment off so it no longer counts in the sum), or just the prepaid status of the channel at that time? | a) Status per transaction · b) Channel status at that time (information only) | **(b)**: status on/off is for the **whole prepaid module** — same Channel-level toggle as Q1/Q2, not a per-transaction status. *(sếp: "status on / off is for al the prepaid module")* |
| 11 | Should **"Skicka"** change from **replacing** the total to **adding** money? Today, when the customer pays 5 000 more, the admin must type the new total (e.g. 10 000 + 5 000 = 15 000). "Each transaction" sounds like: type 5 000 → it is added. | a) Keep replacing the total · b) Add each payment as a transaction; available sum = all payments − deducted orders | **(b)**: log **every transaction as an addition** (don't overwrite the total); rename button **"Skicka" → "Lägg till summa"**. *(sếp: "yes I think its better to add all transaction in log so its easy to see error , and change text skicka to Lägg till summa")* |

### D. Other

| # | Question | Options | Answer |
|---|----------|---------|--------|
| 12 | Should the low-balance warning email be checked **right when an order is created**? Today it is only sent when someone clicks "Skicka". | Our suggestion: yes | Yes, and it should be fully automatic — sếp explicitly asks us to design the mechanism, suggesting a **script run once a day**. *(sếp: "we need to make the calculated sum auto and send email auto , so maybe run the script one time a day or what to do to make it clear ?")* → implemented as a daily cronjob, see design below |
| 13 | Should **channel (guest) logins** with "show prepaid" turned on also see the new prepaid info (order, invoice, lists)? | Our suggestion: yes | **Confirmed**: keep it a **manual, per-channel toggle for GUEST** — keep the existing `guestLoginShowPrepaid` switch per channel as the only control, nothing forced/automatic. *(sếp: "keep to tio manually show it for GUEST", xác nhận lại: "vẫn giữ bên guest login cho mỗi channel")* |
| 14 | Should Betalningstyp = Prepaid be set for orders from **all sources** (admin, webshop, WooCommerce/Shopify/Pinshirt import, API), overriding the default payment type of the customer/channel? | Our suggestion: yes, all sources | **Yes.** *(sếp: "= yes")* |
| 15 | Channels that **already use prepaid** today: should the new on/off switch start as ON? | Our suggestion: ON, so nothing changes for them | **Yes**, default ON. Note: right now only **2-3 test channels** actually use prepaid — must finish this ticket and **inform them before pushing live**. *(sếp: "= yes but they have only test accounts now , 2-3 so we need to finish this and inform them when we push live")* |

---

## Follow-up questions (chưa rõ hoàn toàn, cần hỏi lại sếp trước khi code phần liên quan)

> Viết bằng tiếng Anh để gửi thẳng cho sếp.

| # | Question |
|---|----------|
| Q4b | For "in order we need to manually add downpayment option prepaid": we already have a manual "add downpayment" function (the same one used for bank payments, which reduces "Kvar att betala"). Is it enough to add a payment type **"Prepaid"** to that function, so the admin picks it when marking an invoice as paid by prepaid? Or do you want a new button directly on the order/invoice (e.g. "Pay with prepaid") that creates the downpayment automatically for the invoice amount? Note: this downpayment will **not** reduce the prepaid balance again, because the balance is already reduced when the order is created. |

**Trả lời Q4b (2026-09-29):** *"oh sorry in invoice we need to manually add down payment — so prepaid is only calculated on order"* → Admin tự thêm inbetalning **trên hoá đơn** bằng nút "Lägg till inbetalning" đã có. Prepaid chỉ tính trên order, không tự trừ gì trên hoá đơn. Không làm nút mới; chỉ thêm loại inbetalning **"Prepaid"** vào dropdown loại (`status_list` type `down_payment_row_type`, migration `Version20260929140000`) để admin chọn khi ghi nhận hoá đơn được trả bằng prepaid. Inbetalning này **không** trừ số dư prepaid lần nữa.

---

## Thiết kế kỹ thuật (đã chốt theo trả lời của sếp — 2026-09-29)

> Thay đổi lớn so với bản dự kiến trước: **Q6** (số hiển thị luôn là số dư còn lại của kênh, không phải số bị trừ riêng của từng dòng) và **Q11** (Skicka đổi thành cộng dồn từng giao dịch, không ghi đè) làm đơn giản hoá khá nhiều phần hiển thị nhưng đổi lại logic ghi log. **Q9** bỏ ý tưởng số giao dịch tự động — Payment ID là text admin tự gõ.

### Entity

| Entity | Field mới | Type | Ý nghĩa |
|---|---|---|---|
| `Channel` | `prepaidActive` | boolean, default false | Bật/tắt cả module prepaid của kênh (mockup 01, gộp cả Q1/Q2 và "Status on/off" Q10 — chỉ 1 cờ duy nhất, **không** có status riêng theo từng giao dịch) |
| `ChannelPrepaidLog` | `amount` | float | Số tiền **cộng thêm** ở giao dịch này (delta, Q11) — thay cho việc ghi đè `prepaidSum` như hiện tại |
| `ChannelPrepaidLog` | `paymentId` | string, nullable | Reference admin tự gõ để đối chiếu với thanh toán ngân hàng (Q9) — **không** phải số tự tăng |
| `ChannelPrepaidLog` | *(bỏ ý tưởng `transactionNr` tự động và `active` theo dòng — không còn phù hợp sau Q9/Q10)* | | |

`ChannelPrepaidLog.prepaidSum` (field có sẵn) tiếp tục dùng làm **snapshot tổng sau giao dịch** (audit trail), nhưng giá trị mới = `channel.prepaidSum(cũ) + amount`, không phải giá trị admin gõ trực tiếp nữa.

Migration **viết tay** (không dùng `doctrine:migrations:diff`), tham khảo `Version20260924090615`. Data migration: set `prepaidActive = true` cho kênh có `prepaid_date IS NOT NULL` (Q15 — hiện chỉ ~2-3 kênh test).

Betalningstyp: **dùng lại Förskott**, không tạo type mới (Q5). Vẫn cần 1 cách tham chiếu ổn định tới id của Förskott trong `status_list` (không so sánh theo tên) — thêm param `prepaid_payment_type_id` trong `services.yaml` hoặc constant, vì code sẽ set/khoá field này ở nhiều nơi.

### API (dự kiến)

| Endpoint | Thay đổi |
|---|---|
| `GET/PUT /api/v1/channels/{id}` | Thêm `prepaidActive` (đọc/ghi); `prepaidLogs[]` thêm `amount`, `paymentId` |
| `POST .../channels/{id}/get-prepaid-current-sum` | Đổi ngữ nghĩa input: `prepaidSum` gửi lên là **số tiền cộng thêm**, không phải tổng mới → BE tự `channel.prepaidSum += amount`. Đổi tên field/param cho rõ (VD `amountToAdd`) để tránh nhầm với hành vi cũ. FE đổi text nút "Skicka" → "Lägg till summa" |
| `GET /api/v1/channels/{id}/prepaid-orders` (1633) | Thêm điều kiện `channel.prepaidActive = true` mới tính (Q2 — tắt thì không trừ); thêm cộng lại credit/return order (Q7) |
| Order detail (`OrderService::generateItem`) | Thêm `prepaidActive`, `prepaidCurrentSum` (**số dư còn lại của kênh** — theo Q6, không phải số order này bị trừ); cờ `prepaidLow` (dưới watcher level) cho FE tô đỏ |
| Order list (`generateItemForList` / `generateItemNewList`) | Thêm `prepaidActive`, `prepaidCurrentSum` — tính 1 lần theo `channelId`, gán chung cho mọi dòng cùng kênh |
| Invoice detail (`InvoiceService::generateItemDetail`) | Thêm `prepaidActive`, `prepaidCurrentSum`. **Không đổi** `sumLeftToPay` / `paymentStatus` (Q4 — chỉ hiển thị thông tin) |
| Invoice list (`InvoiceService::generateItem`) | Thêm `prepaidActive`, `prepaidCurrentSum` |
| Tạo order | FE cần số dư khi chọn kênh → dùng lại `GET /channels/{id}/prepaid-orders` hoặc thêm `prepaidCurrentSum`/`prepaidLow` vào channel detail |
| Order/invoice trước `prepaidDate` | Trả về field riêng biệt (VD `prepaidExcluded: true`) thay vì `null`, để FE hiện rõ "không tính" thay vì để trống im lặng (Q8) |
| Khoá Betalningstyp khi dùng prepaid (Q1) | `OrderService::update()` — nếu order thuộc kênh `prepaidActive` và đang là Förskott, từ chối đổi `paymentTypeId` (400) trừ khi có cờ admin override rõ ràng — cần hỏi thêm nếu có ngoại lệ nào |

### Cronjob mới — auto tính + gửi cảnh báo (Q12)

- `src/Command/Cronjob/ChannelPrepaidWatcherCommand.php`, `#[AsCommand(name: 'cronjob:channel-prepaid-watcher')]`, theo mẫu `ShopifyCloneProductStockCommand` (log ra file phẳng, VD `prepaid_logs/watcher_{date}.txt`).
- Chạy 1 lần/ngày (sếp đã chốt). Giờ chạy cụ thể quyết định lúc setup cron khi deploy.
- Người nhận: giữ như hiện tại — `MailService::prepaidSumPassedWatcherLevel()` gửi tới `email_from` (`order@tshirt.se`, hộp mail admin), khớp ý sếp "send email to admin". Không gửi cho khách/kênh (sếp không yêu cầu); nếu sau này cần thì thêm field email nhận cảnh báo theo kênh.
- Khi số dư còn dưới ngưỡng, mỗi ngày gửi lại 1 mail (nhắc hằng ngày tới khi nạp thêm tiền).
- Với mỗi kênh `prepaidActive = true`: tính `prepaidCurrentSum` (dùng lại logic `applyPrepaidOrderCriteria` — **không** ghi log, **không** đổi `channel.prepaidSum`), nếu dưới `prepaidWatcherLevel` → gọi `MailService::prepaidSumPassedWatcherLevel()`.
- Việc tô **RED** + **popup lúc tạo order** là real-time ở FE, dựa vào cờ `prepaidLow` trả về từ API — không phụ thuộc cronjob. Popup hiện **mỗi lần** tạo order mới khi số dư đang dưới ngưỡng (đã chốt, không giới hạn 1 lần/phiên).

### Manual downpayment cho prepaid (Q4)

Đã chốt (Q4b): dùng nút "Lägg till inbetalning" có sẵn trên hoá đơn (`POST /down-payment-rows/`, `DownPaymentRow`), admin chọn loại **"Prepaid"** mới (migration `Version20260929140000`). Không code thêm.

#### ⚠️ Lưu ý bẫy quan trọng

- **Không tính số dư theo từng dòng** trong list order/invoice — gom `channelId` của trang, tính 1 lần/kênh (`getPrepaidOrderSum`), tránh N+1 query. Sau Q6, mọi dòng cùng kênh hiện **cùng 1 số** nên càng nên cache theo kênh trong 1 request.
- **Không dùng `channel.prepaidCurrentSum` để hiển thị trực tiếp** — field này chỉ là snapshot ghi lúc có giao dịch/cronjob chạy, có thể cũ. Luôn tính lại tại thời điểm gọi API hiển thị.
- Đường "Lägg till summa" **vẫn được phép** ghi log + cập nhật `channel.prepaidSum` (đó là hành vi mong muốn mới, khác với "không dùng endpoint tính số dư cũ để hiển thị" ở trên — 2 việc khác nhau: *thêm tiền* vs *chỉ đọc số dư*).
- `applyPrepaidOrderCriteria()` vẫn là **một chỗ duy nhất** để sửa điều kiện trừ tiền — nay thêm điều kiện `prepaidActive` (Q2) và cộng lại credit/return order (Q7); phải sửa ở đây để SUM, log 1633, cronjob mới và mọi field hiển thị luôn khớp nhau.
- Order/invoice trước `prepaidDate`: phải trả cờ loại trừ riêng (Q8), không lẫn với "kênh không bật prepaid" hay "số dư = 0".

---

## TODO List

> Đã chốt theo trả lời sếp (2026-09-29), kể cả Q4b (inbetalning thủ công trên hoá đơn).

### Trước khi code
- [x] Gửi câu follow-up còn lại (Q4b — downpayment) cho sếp → đã trả lời: inbetalning thủ công trên hoá đơn
- [x] Migration `Version20260929140000`: thêm loại inbetalning "Prepaid" (`unique_key = down_payment_row_type_prepaid`)

### Backend — Entity & Migration
- [x] `src/Entity/Channel.php`: thêm `prepaidActive` (boolean, default false) + getter/setter
- [x] `src/Entity/ChannelPrepaidLog.php`: thêm `amount` (float, delta), `paymentId` (string, nullable) — **không** thêm `transactionNr`/`active` (bỏ theo Q9/Q10)
- [x] Migration viết tay `Version20260929090000`: ADD COLUMN `prepaid_active`, `amount`, `payment_id` + backfill `prepaid_active = true` cho kênh có `prepaid_date IS NOT NULL` (Q15) + backfill `amount` cho log cũ (tổng dòng này − tổng dòng trước, cùng kênh)
- [x] Tham chiếu Förskott qua `status_list.unique_key = 'order_payment_type_prepaid'` (`StatusList::PAYMENT_TYPE_KEY_PREPAID`), migration gán theo `name = 'Förskott'` — không tạo Betalningstyp mới (Q5)
- [x] Đã kiểm tra DB (2026-09-29): đúng 1 dòng Förskott (`status_list.id = 125`), `unique_key` đang trống → migration gán được
- [ ] Chạy `doctrine:migrations:migrate`

### Backend — Service
- [x] `ChannelService::generateItem()` / `update()`: đọc/ghi `prepaidActive`; `prepaidLogs` thêm `amount`, `paymentId`, sắp xếp mới nhất trước. PUT channel **bỏ qua** `prepaidSum`/`prepaidCurrentSum` — tổng chỉ đổi qua "Lägg till summa" để luôn có log
- [x] `OrderService::getChannelPrepaidCurrentSum()`: **cộng dồn** `channel.prepaidSum += amount` (Q11); param mới `amount` + `paymentId`, param cũ `prepaidSum` bị bỏ qua (tránh FE cũ gửi tổng bị cộng nhầm). Chỉ ghi log khi có thay đổi thật (có tiền nạp, hoặc đổi ngưỡng/ngày). **Bỏ gửi email khi bấm nút** — email giờ chỉ do cronjob gửi (Q12)
- [x] Một hàm tính số dư duy nhất `OrderService::calculatePrepaidBalance()` = `prepaidSum − order + credit` (Q2: kênh tắt → `null`, không trừ gì; Q7: credit tạo sau `prepaidDate` được cộng lại, `CreditRepository::getPrepaidCreditSum()`). Log 1633, field hiển thị và cronjob đều gọi hàm này
- [x] `OrderService::add()` (mọi luồng: admin, webshop, WooCommerce/Shopify/Pinshirt import, API): kênh `prepaidActive` → luôn gán Förskott, ghi đè default customer/channel và cả loại thanh toán import về (Q14)
- [x] `OrderService::update()`: order bị trừ prepaid (kênh bật + order sau `prepaidDate`) → không cho đổi Betalningstyp (400); gửi lại đúng giá trị cũ (lưu cả form) thì không báo lỗi. Order trước `prepaidDate` vẫn sửa tự do (Q1)
- [x] `OrderService::generateItemForList()` (dùng cho mọi output order): thêm `prepaidActive`, `prepaidCurrentSum` (số dư còn lại của kênh — Q6), `prepaidLow`, `prepaidExcluded` (Q8), `prepaidGuestVisible`; cache 1 lần/kênh/request
- [x] `InvoiceService::generateItem()` (list + detail): thêm cùng các field trên, `prepaidExcluded` theo `dateInvoice`; **không** đổi `sumLeftToPay` / `paymentStatus` (Q4)
- [x] Cronjob `cronjob:channel-prepaid-watcher` (`src/Command/Cronjob/ChannelPrepaidWatcherCommand.php`): mỗi kênh `prepaidActive` → cập nhật snapshot `prepaidCurrentSum`, gửi email về `order@tshirt.se` khi dưới ngưỡng, log ra `prepaid_logs/watcher_{date}.txt`; **không** ghi `ChannelPrepaidLog`, không đổi `prepaidSum` (Q12)
- [ ] Setup cron trên server (1 lần/ngày, giờ chạy quyết định lúc deploy)

### Backend — Controller & Routing
- [x] Không cần route mới
- [ ] Guest (Q13): API order/invoice hiện chưa phân quyền theo guest, nên BE trả cờ `prepaidGuestVisible` (= `guestLoginShowPrepaid`) để FE ẩn với tài khoản guest — giống cách channel detail đang làm

### Ghi chú thiết kế khi code
- `ReturnOrder` **không** được cộng lại, chỉ `Credit` (kredit), để tránh cộng 2 lần khi return order sinh ra credit. Cần xác nhận lại nếu có return order không đi kèm credit.
- Credit của 1 order chỉ được cộng lại khi **order gốc** đã bị trừ (ngày order > `prepaidDate`); credit không gắn order thì so `dateCreated` với `prepaidDate`.
- `add()` chỉ gán Förskott khi order **thực sự bị trừ** (kênh bật + ngày order sau `prepaidDate`); order import có ngày cũ giữ nguyên loại thanh toán.
- Channel detail (`GET /channels/{id}`) trả `prepaidCurrentSum` **tính trực tiếp** + `prepaidLow` khi kênh bật prepaid (field lưu trong DB chỉ là snapshot, cũ đi sau mỗi order mới) → tab Prepaid và màn tạo order dùng được luôn.
- Cronjob bỏ qua kênh `active = false`.

### Review 2026-09-29 — điểm còn mở
- **Tắt rồi bật lại prepaid (Q2):** quy tắc trừ tiền dựa theo ngày, nên order tạo **trong lúc tắt** sẽ bị trừ khi bật lại, mà các order này không mang Förskott. Cách đơn giản: khi bật lại thì admin đặt `prepaidDate` mới. Nếu sếp muốn tự động loại các order tạo lúc tắt thì cần lưu thêm lịch sử bật/tắt.
- **Guest (Q13):** chỉ FE ẩn theo `prepaidGuestVisible`, API không chặn.
- **Hiệu năng:** mỗi request list order/invoice/channel tốn thêm 1 `find` + 2 query SUM cho **mỗi kênh** có mặt trong trang (có cache trong request). `orders` đã có index `(channel_id, date_order)`; `credits` chưa có index `channel_id` — hiện bảng nhỏ nên chưa cần.
- **Smoke test DB local (kênh 88):** số dư 1253.75 (khớp ví dụ 1633), order cũ `prepaidExcluded = true`, channel detail trả số dư trực tiếp.
- Hoá đơn gộp nhiều order: `prepaidExcluded` tính theo `dateInvoice`, nên hoá đơn tạo sau ngày prepaid nhưng chứa order cũ vẫn hiện số dư bình thường.

### Frontend — React *(repo khác)*
- [ ] Tab Prepaid: toggle on/off (module-level, Q10), cột **Payment ID** (text nhập tay) + đổi nút "Skicka" → **"Lägg till summa"** (Q11)
- [ ] Tạo order: hiện số dư còn lại khi chọn kênh bật prepaid; tô **đỏ** + popup cảnh báo **mỗi lần** tạo order mới khi số dư dưới ngưỡng (Q3)
- [ ] Chi tiết order: Betalningstyp Förskott/Prepaid **khoá không cho sửa** khi dùng prepaid (Q1) + ô số dư còn lại
- [ ] Chi tiết hoá đơn: ô *Prepaid* trong thanh thanh toán (chỉ thông tin, không đổi Kvar att betala — Q4)
- [ ] Danh sách *Att fakturera löpande* + danh sách hoá đơn: cột prepaid (chỉ khi `prepaidActive`); dòng trước `prepaidDate` hiện rõ "không tính" thay vì để trống (Q8)
- [x] Viết `frontend-api.md` trong folder này (chia theo 7 mockup)

### Test / kiểm tra
- [x] Unit test `tests/Service/OrderServicePrepaidTest.php` (23 test): số dư khi bật/tắt, cộng lại credit, số âm + `prepaidLow`, quy tắc ngày (`isDeductedFromPrepaid`), field hiển thị + cache theo kênh, "Lägg till summa" cộng dồn + log + bỏ qua `prepaidSum` cũ + không gửi email, khoá Betalningstyp (`applyPrepaidPaymentTypeLock`)
- [x] Sửa test 1633 `OrderServicePrepaidOrdersTest.php` cho khớp (kênh bật `prepaidActive`, mock `CreditRepository`) + thêm test kênh tắt. Toàn bộ suite: 54/54 pass
- [ ] Chưa có test tự động cho: `add()` gán Förskott (nhiều dependency), cronjob, SQL của repository/migration → kiểm tra tay trên kênh test
- [ ] Kênh test hiện có (2-3 kênh, Q15): bật/tắt prepaid, tạo order → Betalningstyp bị khoá, số dư, cột list khớp với log 1633
- [ ] Số dư còn lại giống hệt nhau ở mọi màn hình cùng thời điểm (Q6)
- [ ] Kênh không bật prepaid: không trừ tiền, không hiện gì, không đổi Betalningstyp (Q2)
- [ ] Hoá đơn gộp nhiều order + hoá đơn kredit/return order cộng lại đúng số dư (Q7)
- [ ] "Lägg till summa" cộng dồn đúng, không ghi đè; log hiện đủ từng giao dịch + Payment ID
- [ ] Cronjob watcher chạy đúng, gửi email khi dưới ngưỡng, không ghi log/không đổi `prepaidSum`
- [ ] Trước khi push live: thông báo cho các khách hàng đang dùng 2-3 kênh test (Q15)

---

## Các file liên quan

| File | Mục đích |
|------|----------|
| `src/Entity/Channel.php` | Field prepaid của kênh (`:317-344`) |
| `src/Entity/ChannelPrepaidLog.php` | Lịch sử nạp |
| `src/Entity/Order.php` | `paymentType`, `paymentTypeId` (`:309-312`) |
| `src/Entity/StatusList.php` | `TYPE_ORDER_PAYMENT_TYPE` |
| `src/Service/ChannelService.php` | `generateItem()` (`:37`), `prepaidLogs` (`:222`), `update()` |
| `src/Service/OrderService.php` | `generateItem()` (`:84`), list, tạo order (`:1184`), `getChannelPrepaidCurrentSum()` (`:5507`), `getChannelPrepaidOrders()` (`:5598`) |
| `src/Repository/OrderRepository.php` | `getPrepaidOrderSum()`, `getPrepaidOrders()`, `applyPrepaidOrderCriteria()` (`:463-525`) |
| `src/Service/InvoiceService.php` | `generateItem()` (`:48`), `generateItemDetail()` (`:79`), `sumLeftToPay` (`:~414`) |
| `src/Service/MailService.php` | `prepaidSumPassedWatcherLevel()` |
| `src/Application/ApiBundle/Resources/config/route/channel.yaml` | Route `get-prepaid-current-sum`, `prepaid-orders` |
| `docs/issues/TSHIRTORDER-1633-prepaid-transaction-log/` | Ticket trước — log đơn bị trừ + bối cảnh prepaid |
