# TSHIRTORDER-1633 — Prepaid sum: show log of transactions

## Yêu cầu gốc

> TSHIRTORDER-1633 prepaid sum- show log of transactions
>
> **Log prepaid**
> **Show all order id and sum that is removed from tillgänglig summa**

Kèm ảnh mockup (xem `files/prepaid-log-mockup.png`) — màn hình `Redigera kanal` → tab **Prepaid** của kênh #93 (Stallzet test).

---

## Tổng quan

Trong tab Prepaid của kênh, ngoài bảng lịch sử các lần nạp tiền (đã có), cần thêm mục **"Log prepaid"** liệt kê từng đơn hàng đã bị trừ vào *Tillgänglig summa inkl moms*: order id + số tiền bị trừ. Mục đích: khách/admin đối chiếu được vì sao số dư giảm.

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

> ⚠️ **Phát hiện quan trọng: phần "lịch sử nạp tiền" đã có sẵn, nhưng log từng đơn bị trừ thì CHƯA có**
>
> | Thành phần | File | Trạng thái |
> |---|---|---|
> | Entity lịch sử nạp tiền `ChannelPrepaidLog` (bảng `channels_prepaid_logs`) | `src/Entity/ChannelPrepaidLog.php` | ✅ Có — chỉ lưu snapshot mỗi lần bấm *Skicka* (ngày, số nạp, ngưỡng, số dư lúc đó). Đây là bảng đang hiện trong mockup. |
> | Trả lịch sử nạp ra API (`prepaidLogs`) | `src/Service/ChannelService.php:222` (`generateItem`) | ✅ Có |
> | Tính số dư hiện tại | `OrderService::getChannelPrepaidCurrentSum()` — `src/Service/OrderService.php:5505` | ✅ Có |
> | Tổng tiền đơn bị trừ | `OrderRepository::getPrepaidOrderSum()` — `src/Repository/OrderRepository.php:463` | ✅ Có — nhưng chỉ trả **1 con số tổng** (`SUM`) |
> | **Danh sách từng đơn bị trừ** | — | ❌ Chưa có |

**Không cần bảng mới**: số dư không được trừ dần theo từng giao dịch mà được **tính lại** mỗi lần, nên danh sách đơn có thể suy ra từ đúng điều kiện của query tổng hiện tại.

---

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

### Prepaid là gì

Một số kênh B2B (VD: Stallzet) **trả trước một khoản tiền** cho tshirt.se. Sau đó mọi đơn của kênh đặt sau ngày trả trước sẽ được "trừ" vào khoản này. Admin theo dõi còn bao nhiêu (*Tillgänglig summa*) và muốn được **báo email khi số dư xuống dưới ngưỡng** để nhắc khách nạp thêm.

### Dữ liệu

| Nơi lưu | Field | Ý nghĩa |
|---|---|---|
| `Channel` (`src/Entity/Channel.php:317-344`) | `prepaidSum` | *Förinbetalt belopp inkl. moms* — tổng tiền khách đã trả trước |
| | `prepaidWatcherLevel` | *Bevakningsnivå skicka email* — ngưỡng gửi email cảnh báo |
| | `prepaidDate` | *Förinbetalt datum & tid* — mốc bắt đầu tính đơn bị trừ |
| | `prepaidCurrentSum` | *Tillgänglig summa* — kết quả **lần tính gần nhất** (cache) |
| | `guestLoginShowPrepaid` | Tài khoản kênh (guest) có xem được prepaid không |
| `channels_prepaid_logs` (`ChannelPrepaidLog`) | `prepaidDate`, `prepaidSum`, `prepaidWatcherLevel`, `prepaidCurrentSum`, `dateCreated` | 1 dòng snapshot **mỗi lần gọi API tính số dư** — là bảng bên phải trong mockup |

Không có cờ bật/tắt prepaid riêng — kênh có `prepaidDate` là coi như đang dùng.

### Luồng

```
Admin nhập 3 ô ở tab Prepaid → bấm "Skicka"
→ POST /api/v1/channels/{id}/get-prepaid-current-sum
   { prepaidSum, prepaidWatcherLevel, prepaidDateString: "Y-m-d H:i:s" }
→ ChannelController::getPrepaidCurrentSum()
→ OrderService::getChannelPrepaidCurrentSum()          (OrderService.php:5505)
   1. Ghi đè prepaidSum / watcherLevel / prepaidDate lên Channel (ô nào không gửi thì giữ nguyên)
   2. OrderRepository::getPrepaidOrderSum() → SUM(totalSum), SUM(totalTax)
   3. currentSum = prepaidSum − (totalSum + totalTax)
   4. INSERT 1 dòng ChannelPrepaidLog
   5. channel.prepaidCurrentSum = currentSum
   6. currentSum < watcherLevel → MailService::prepaidSumPassedWatcherLevel()
→ trả { current_prepaid_sum, channel_prepaid_sum, order_sum, order_tax }
```

### Ví dụ minh hoạ (số liệu giả định, kênh 93 — Stallzet test)

**17/8 09:30** — Stallzet chuyển khoản 10 000 kr. Admin nhập `10000` / ngưỡng `3000` / ngày `2026-08-17 09:30` → **Skicka**.
→ Chưa có đơn nào sau 17/8 23:59:59 → *Tillgänglig summa* = **10 000**. Log +1 dòng.

Sau đó Stallzet đặt các đơn:

| Đơn | Ngày đặt | totalSum + totalTax | Có bị trừ? | Lý do |
|---|---|---|---|---|
| #10220 | 17/8 14:00 | 800 + 200 = 1 000 | ❌ | Cùng ngày nạp — query chỉ lấy từ **sau 17/8 23:59:59** |
| #10234 | 20/8 | 800 + 200 = 1 000 | ✅ | |
| #10240 | 22/8 | 200 + 50 = 250 | ✅ | |
| #10251 | 25/8 | 1 600 + 400 = 2 000 | ❌ | Đơn bị `cancel` |
| #10260 | 01/9 | 6 000 + 1 500 = 7 500 | ✅ | |

**Từ 20/8 → 4/9 không ai bấm Skicka** → DB vẫn lưu `prepaidCurrentSum = 10 000`, **không có email nào được gửi** dù thực tế số dư đã xuống 1 250 từ 1/9.

**05/9** — admin mở tab Prepaid, bấm **Skicka** (không đổi gì):
→ tính lại: 10 000 − (1 000 + 250 + 7 500) = **1 250** < 3 000 → gửi email cảnh báo. Log +1 dòng.
Bấm Skicka thêm lần nữa → log +1 dòng **và gửi thêm 1 email** giống hệt.

**10/9** — Stallzet nạp thêm 5 000 kr. Vì Skicka **ghi đè** chứ không cộng dồn, admin có 2 cách:
- Giữ ngày 17/8, nhập `15000` (10 000 + 5 000) → 15 000 − 8 750 = **6 250**. ✅ Các đơn cũ vẫn nằm trong log.
- Đổi ngày thành 10/9, nhập `6250` (1 250 còn lại + 5 000) → cũng ra **6 250**, nhưng các đơn #10234, #10240, #10260 **biến mất** khỏi phép tính (và khỏi log mới của ticket này).

**Với ticket này**, mục *Log prepaid* ở ví dụ trên (cách 1) sẽ hiện:

| Order id | Datum | Summa inkl moms |
|---|---|---|
| #10260 | 2026-09-01 | 7 500 |
| #10240 | 2026-08-22 | 250 |
| #10234 | 2026-08-20 | 1 000 |
| **Totalt** | | **8 750** |

→ 15 000 − 8 750 = 6 250 = *Tillgänglig summa*. Khách đối chiếu được ngay.

### Những điểm dễ quên (ảnh hưởng tới ticket)

1. **Không có cronjob, không có trigger khi tạo đơn.** Số dư & email cảnh báo **chỉ chạy khi FE gọi `get-prepaid-current-sum`**. `channel.prepaidCurrentSum` trong DB có thể cũ → log mới phải tự tính lại, không đọc field này.
2. **Mỗi lần gọi = +1 dòng `ChannelPrepaidLog` + có thể +1 email.** Dưới ngưỡng thì bấm bao nhiêu lần gửi bấy nhiêu email. Nếu FE gọi API này mỗi lần mở tab thì log + email sẽ bị lặp. *(Chưa kiểm tra được FE — repo khác.)*
3. **Email cảnh báo gửi tới `email_from`** (chính địa chỉ gửi, nội bộ), không gửi cho khách. Template: `EmailTemplate.emailKey = prepaid_sum_passed_watcher_level`. `docs/spec/02-du-lieu-kenh-cau-hinh.md` ghi là `email_admin` → spec đang lệch code.
4. Mốc lấy **cuối ngày** nạp, không lấy giờ (đơn #10220 ở ví dụ).
5. `ReturnOrder` **không** được cộng lại vào số dư; đơn `cancel` / soft delete thì tự động không bị trừ.
6. `prepaidLogs` trong `generateItem()` không có `orderBy` → thứ tự dòng không đảm bảo.

---

## Chi tiết theo mockup

Mockup là tab **Prepaid** hiện tại:

- Bên trái: form *Förinbetalt belopp inkl. moms* / *Bevakningsnivå skicka email* / *Förinbetalt datum & tid* + nút **Skicka**, bên dưới là *Tillgänglig summa inkl moms: 10000 kr*.
- Bên phải: bảng lịch sử nạp tiền (`prepaidLogs`) — giữ nguyên.
- **Mới**: phía dưới toàn bộ, thêm mục tiêu đề **"Log prepaid"** — bảng liệt kê các đơn đã trừ vào tillgänglig summa, mỗi dòng: **order id** + **số tiền bị trừ** (đề xuất thêm ngày đặt hàng + tên khách để dễ đối chiếu), có dòng tổng cuối bảng.

### Logic trừ tiền hiện tại (log phải khớp 100%)

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

Đơn được tính là "bị trừ" khi thoả **tất cả**:

| Điều kiện | Code |
|---|---|
| `order.channelId = channel.id` | `getPrepaidOrderSum` |
| `order.dateDeleted IS NULL` | |
| `order.cancel = false` | |
| `order.dateOrder > <ngày prepaidDate> 23:59:59` | lấy **cuối ngày** nạp, không lấy giờ nạp |

Số tiền trừ của 1 đơn = `totalSum + totalTax`.

---

## Pending Clarification

| # | Câu hỏi | Phương án |
|---|---------|-----------|
| 1 | **Mốc thời gian trừ tiền**: query lọc `dateOrder > prepaidDate 23:59:59` (cuối ngày), không theo giờ nạp. VD kênh 93 nạp lúc 2026-08-17 09:30 → các đơn ngày 17/8 sau 09:30 **không bị trừ**. Đây là chủ ý hay bug? | a) Giữ nguyên (log dùng cùng điều kiện) · b) Sửa thành `dateOrder >= prepaidDate` (theo giờ) — sẽ làm thay đổi số dư hiện tại của các kênh đang dùng prepaid |
| 2 | **Đơn trả hàng (`ReturnOrder`)** hiện **không** được cộng lại vào số dư. Log có cần hiện dòng hoàn tiền (số dương) không? | a) Không (giữ như hiện tại) · b) Có — phải sửa luôn công thức số dư |
| 3 | Chỉ hiện đơn tính từ **lần nạp gần nhất** (đúng logic hiện tại — mỗi lần *Skicka* ghi đè `prepaidSum`, không cộng dồn) hay hiện toàn bộ lịch sử theo từng kỳ nạp? | Đề xuất a) chỉ kỳ hiện tại |
| 4 | Tài khoản kênh (guest, `guestLoginShowPrepaid = true`) có được xem log này không? | Đề xuất: có, giống số dư |
| 5 | Số dư + email cảnh báo chỉ cập nhật khi có người bấm *Skicka* (xem ví dụ: số dư xuống 1 250 từ 1/9 nhưng 5/9 mới có email). Có cần tự động không? | a) Giữ nguyên — ngoài scope ticket · b) Thêm cronjob `cronjob:prepaid-check` hằng ngày cho các kênh có `prepaidDate` · c) Tính lại sau khi tạo/cancel đơn |
| 6 | Bấm *Skicka* nhiều lần khi đang dưới ngưỡng → gửi email lặp. Có cần chặn không? | a) Giữ nguyên · b) Chỉ gửi khi số dư **vừa** vượt xuống dưới ngưỡng (lần trước ≥ ngưỡng, lần này < ngưỡng) |
| 7 | Email cảnh báo đang gửi tới `email_from` (nội bộ). Đúng người nhận chưa, hay cần gửi thêm cho khách / email của kênh? | Hỏi sếp |

---

## API contract / Thiết kế kỹ thuật

Route riêng (không nhét vào `GET /channels/{id}` vì `generateItem` đã rất nặng và danh sách đơn có thể dài). ✅ **Đã implement.**

**`GET /api/v1/channels/{id}/prepaid-orders`** — Bearer token

Query params (optional, theo convention `limit`/`offset` của `OrderService::list()`): `limit` (default 50, giới hạn `1..500` — `OrderService::PREPAID_ORDERS_MAX_LIMIT`), `offset` (default 0, số âm → 0). Mọi số tiền được `round(..., 2)`.

Route này **chỉ đọc**: không ghi `ChannelPrepaidLog`, không cập nhật `channel.prepaidCurrentSum`, không gửi email cảnh báo → FE gọi mỗi lần mở tab thoải mái.

Response `200`:

```json
{
  "prepaidDate": "2026-08-17T09:30:00+02:00",
  "prepaidSum": 10000,
  "prepaidWatcherLevel": 3000,
  "prepaidCurrentSum": 8750,
  "totalDeducted": 1250,
  "total": 2,
  "items": [
    {
      "orderId": 10240,
      "orderNr": "31622",
      "orderCountNr": 74080,
      "customerName": "Stallzet AB",
      "dateOrder": "2026-08-22T14:05:00+02:00",
      "totalSum": 200,
      "totalTax": 50,
      "deductedSum": 250
    },
    {
      "orderId": 10234,
      "orderNr": "31610",
      "orderCountNr": 74071,
      "customerName": "Stallzet AB",
      "dateOrder": "2026-08-20T10:12:00+02:00",
      "totalSum": 800,
      "totalTax": 200,
      "deductedSum": 1000
    }
  ]
}
```

| Field | Ý nghĩa |
|---|---|
| `prepaidCurrentSum` | **Tính lại tại thời điểm gọi** = `prepaidSum − totalDeducted` (không đọc field đã lưu trên Channel). FE nên hiện số này làm *Tillgänglig summa* để luôn khớp với log. |
| `totalDeducted` | Tổng `deductedSum` của **tất cả** đơn thoả điều kiện (không chỉ trang hiện tại) |
| `total` | Tổng số đơn (để phân trang) |
| `items[].deductedSum` | `totalSum + totalTax` — số tiền đơn này trừ vào prepaid |
| `items[].orderNr` / `orderCountNr` | FE chọn số hiển thị theo `channel.switchOrderNrOrderCountNr` giống các màn hình order khác |

- `404` nếu không tìm thấy channel, `401` nếu token sai.
- `403` nếu user `ROLE_CHANNEL` không thuộc kênh (`UserMultiChannel`) hoặc kênh tắt `guestLoginShowPrepaid`.
- Channel chưa có `prepaidDate` → `items: []`, `totalDeducted: 0`, `prepaidCurrentSum = prepaidSum`.
- Sắp xếp `dateOrder DESC`, `id DESC`.

Không có thay đổi entity / migration.

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

- **Log và số dư phải dùng chung 1 bộ điều kiện lọc.** Nếu viết query mới copy-paste điều kiện, sau này sửa 1 chỗ sẽ lệch chỗ kia → `totalDeducted` ≠ `prepaidSum − prepaidCurrentSum`. Nên tách điều kiện lọc ra 1 private method trong `OrderRepository` dùng chung cho `getPrepaidOrderSum()` và method mới.
- `channel.prepaidCurrentSum` chỉ được cập nhật khi FE gọi `POST /channels/{id}/get-prepaid-current-sum`, nên giá trị lưu trong DB có thể cũ hơn danh sách đơn. Response của route mới **tính lại** `prepaidCurrentSum = prepaidSum − totalDeducted` thay vì đọc field đã lưu.

---

## TODO List

### Backend — Repository
- [x] `src/Repository/OrderRepository.php`: tách điều kiện lọc của `getPrepaidOrderSum()` (channelId, `dateDeleted IS NULL`, `cancel = false`, `dateOrder > prepaidDate 23:59:59`) thành private method `applyPrepaidOrderCriteria($qb, $criteria)`; `getPrepaidOrderSum()` trả thêm `total` (COUNT)
- [x] Thêm `getPrepaidOrders($criteria)` dùng chung method trên, select `orderId, orderNr, orderCountNr, customerName, dateOrder, totalSum, totalTax`, order by `dateOrder DESC, id DESC`, `limit`/`offset`

### Backend — Service
- [x] `src/Service/OrderService.php`: thêm `getChannelPrepaidOrders($channelId, $data)` — 404 nếu không có channel, tính `deductedSum` từng đơn, `totalDeducted` + `total` từ `getPrepaidOrderSum()` (toàn bộ, không theo trang), `prepaidCurrentSum` tính lại live

### Backend — Controller & Routing
- [x] `src/Application/ApiBundle/Controller/ChannelController.php`: thêm action `getPrepaidOrders()` theo pattern `getPrepaidCurrentSum()` (check token → 401)
- [x] `src/Application/ApiBundle/Resources/config/route/channel.yaml`: thêm route `api_channel_get_prepaid_orders` — `GET /{id}/prepaid-orders`
- [x] Chặn guest: `ROLE_CHANNEL` chỉ xem được kênh của mình (`UserMultiChannel`) + kênh bật `guestLoginShowPrepaid`, ngược lại `403`. Controller lấy `User` qua `getToken(..., getUser: true)`. *(Có hiện log ở giao diện guest hay không vẫn chờ câu hỏi 4.)*
- [x] Validate `limit` (1..500) / `offset` (≥ 0) — trước đó `offset=-1` gây lỗi SQL → 500 lộ câu SQL
- [x] `round(..., 2)` cho `deductedSum`, `totalDeducted`, `prepaidCurrentSum` (cột `float`)

### Backend — Index
- [x] `src/Entity/Order.php`: `#[ORM\Index(name: 'idx_orders_channel_id_date_order', columns: ['channel_id', 'date_order'])]`
- [x] Migration viết tay `migrations/Version20260925065840.php` — **không dùng `doctrine:migrations:diff`**: diff tự sinh kéo theo `DROP TABLE activity_log_yYYYYmMM` (mất dữ liệu) + drop index Tryck (xem `Version20260924090615`). Đã chạy trên local.

### Frontend — React *(repo FE riêng)*
- [ ] Tab Prepaid của `Redigera kanal`: thêm mục **"Log prepaid"** dưới bảng lịch sử nạp tiền, gọi `GET /channels/{id}/prepaid-orders`
- [ ] Cột: Order id (link sang trang chi tiết đơn), Datum, Kund, Summa inkl moms (`deductedSum`); dòng tổng `totalDeducted`; phân trang
- [ ] Reload lại log sau khi bấm **Skicka**
- [ ] Kiểm tra FE có gọi `POST /channels/{id}/get-prepaid-current-sum` khi **mở tab** không — nếu có thì mỗi lần mở tab sẽ +1 dòng `ChannelPrepaidLog` và có thể +1 email (xem "Những điểm dễ quên" #2). Route log mới là `GET`, không ghi gì, nên dùng route này để hiển thị.

### Test / kiểm tra
- [x] Unit test `tests/Service/OrderServicePrepaidOrdersTest.php` (mock EM, không cần DB) — 13 test: 404, không có `prepaidDate`, tính `deductedSum`/`totalDeducted`/`prepaidCurrentSum`, total lấy từ query SUM chứ không từ trang hiện tại, làm tròn float, clamp `limit`/`offset` (5 case), guest khác kênh / kênh tắt `guestLoginShowPrepaid` → 403, guest đúng kênh → 200. Chạy: `php vendor/bin/phpunit tests/Service/OrderServicePrepaidOrdersTest.php`
- [ ] appdev: `?offset=-1` → 200 (không còn 500); token guest kênh khác → 403
- [x] Local DB, kênh 88 (prepaidDate 2026-07-03, prepaidSum 7 000): list 17 đơn, tổng list = `getPrepaidOrderSum()` = 5 746.25 ✅. Số đã lưu `prepaidCurrentSum` = 5 587.5 trong khi tính lại live = 1 253.75 → xác nhận đúng vấn đề số dư bị cũ ("Những điểm dễ quên" #1)
- [ ] Kênh 93 trên appdev: gọi API thật với token, so `totalDeducted` với `prepaidSum − prepaidCurrentSum` ngay sau khi bấm Skicka
- [ ] Đơn bị cancel / soft delete / đặt trước ngày nạp → không xuất hiện trong log
- [ ] Đơn đặt trong ngày nạp (sau giờ nạp) → hành vi theo câu trả lời câu hỏi 1
- [ ] Channel không có `prepaidDate` → danh sách rỗng, không lỗi

---

## Các file/files liên quan

| File | Mục đích |
|------|----------|
| `src/Entity/ChannelPrepaidLog.php` | Lịch sử nạp tiền (đã có, giữ nguyên) |
| `src/Service/ChannelService.php` (`generateItem`, dòng ~222) | Trả `prepaidLogs` ra FE (đã có) |
| `src/Service/OrderService.php` (`getChannelPrepaidCurrentSum`, dòng ~5505) | Công thức tính số dư — thêm method log mới cạnh đây |
| `src/Repository/OrderRepository.php` (`getPrepaidOrderSum`, dòng ~463) | Điều kiện lọc đơn bị trừ — tách dùng chung |
| `src/Application/ApiBundle/Controller/ChannelController.php` | Thêm action mới |
| `src/Application/ApiBundle/Resources/config/route/channel.yaml` | Thêm route mới |
| `src/Entity/Order.php` + `migrations/Version20260925065840.php` | Index `(channel_id, date_order)` |
| `tests/Service/OrderServicePrepaidOrdersTest.php` | Unit test |
| `src/Service/MailService.php` (`prepaidSumPassedWatcherLevel`) | Email cảnh báo dưới ngưỡng — không đổi |
| `docs/spec/02-du-lieu-kenh-cau-hinh.md` | Spec phần prepaid của Channel |
