# TSHIRTORDER-1546 — Share Payment Link

## Yêu cầu gốc

> 1. Add button in order detail page
> 2. Button send email with link to payment
> 3. Load checkout page - load checkout with products in order
> 4. After payment we add info in comment and change status "send payment link"
> 5. After payment success send payment confirm
> 6. We change status to Pay by link
> 7. 2 email template - email with link + payment confirmation

Kèm 2 ảnh mockup (xem `files/mockup-order-detail-send-button.png` và `files/mockup-payment-link-checkout.png`).

---

## Implementation Status (2026-08-20)

✅ **Backend đã implement xong** (entity, 2 migrations đã chạy trên DB, SveaService, MailService, OrderService, OrderController + API route, PaymentLinkController + webshop routing, 3 twig templates) — chi tiết từng mục xem TODO list bên dưới, các mục `[x]` đều đã code + lint (`php -l`, `lint:twig`) + verify DB/router thật, không phải chỉ viết plan.

⏳ **Chưa làm**: Frontend React (repo khác — button + popup trong order detail page).

---

## ⚠️ Go-live checklist — cần làm khi deploy lên production

Danh sách việc **phải kiểm tra/chỉnh trước khi live**, không tự động đúng khi deploy:

- [ ] **Migrations chưa chạy trên production** — cần chạy `doctrine:migrations:migrate` với 4 migration mới:
  - `Version20260820022905.php` (5 field payment link trên `orders`)
  - `Version20260820023200.php` (2 status mới `Send Payment Link`/`Pay By Link` vào `status_list`)
  - `Version20260820033742.php` (field `payment_link_email`)
  - `Version20260820061500.php` (seed sẵn 2 email template `payment_link_send`/`payment_link_confirm` vào DB — **chạy xong nhớ vào `EmailTemplateController` sửa nội dung ngay**, xem mục ngay bên dưới)

- [ ] **`SVEA_MERCHANT_SECRET` trên server live phải là secret LIVE thật** — trong `.env` hiện tại (commit), dòng `###LIVE###` đang bị comment (`###SVEA_MERCHANT_SECRET=...###LIVE###`), còn dòng `###DEV###` đang active. **Trước khi deploy `.env` lên production**: bỏ comment dòng `###LIVE###`, comment/xoá dòng `###DEV###` (hoặc theo đúng cách server production của bạn thật sự nạp biến môi trường — `.env` thật trên server / `.env.local.php` / env var thật của hệ điều hành/container — nếu khác với `.env` trong repo thì sửa ở đúng chỗ đó).

- [ ] **`SVEA_USE_TEST` phải là `0` (hoặc không set) trên production** — trong `.env` (commit) đã mặc định `0`, **không cần sửa gì thêm ở đây**. Chỉ cần đảm bảo **không có file `.env.local` nào bị deploy lên server** (file này đang set `SVEA_USE_TEST=1` trên máy dev, dùng để test — chỉ tồn tại local, đã nằm trong `.gitignore` nên bình thường không bị deploy qua git; nếu quy trình deploy có copy file thủ công/rsync nguyên thư mục thì cần tự check lại, đừng vô tình copy `.env.local` lên server). Xem chi tiết cơ chế ở "Self-review round 7" bên dưới.

  | | `.env` (commit, deploy lên live) | `.env.local` (máy dev, KHÔNG được lên live) |
  |---|---|---|
  | `SVEA_USE_TEST` | `0` ✅ đã đúng sẵn | `1` (chỉ để test local) |
  | `SVEA_MERCHANT_SECRET` | ⚠️ đang là secret DEV — **cần đổi sang LIVE trước deploy** | không cần khai báo lại (kế thừa từ `.env`) |

- [ ] **Domain trong link email phải đúng domain thật** — link thanh toán gửi cho khách được build bằng `$router->generate('payment_link_checkout', [...], UrlGeneratorInterface::ABSOLUTE_URL)`. Khi chạy qua 1 API request (staff bấm "Send payment" từ FE) thì Symfony tự lấy đúng host từ request đó, **không cần config thêm** — nhưng nên gửi thử 1 email thật trên production để mắt thấy link đúng domain (không phải `ts_new_order_system.loc` như lúc dev), tránh trường hợp có proxy/load balancer che host thật.

- [ ] **Webhook Svea cần domain public thật** — `pushUri`/`webhookUri`/`confirmationUri`/`checkoutUri`/`termsUri` gửi cho Svea lúc tạo order đều tự build từ domain request hiện tại. Trên local (`*.loc`) Svea **không gọi ngược vào được** nên toàn bộ luồng push/webhook **chưa từng được test thật** — bắt buộc phải test 1 đơn hàng thật (thanh toán thật hoặc dùng Svea test card ở sandbox) trên môi trường live/staging có domain public trước khi công bố tính năng cho khách dùng thật.

- [ ] **Đối chiếu lại tổng tiền Svea hiển thị với `order.totalSumInclTax`** khi test đơn thật (đã fix bug thiếu VAT ở "Self-review round 9", nhưng nên tự mắt kiểm tra lại 1 lần nữa trên order thật trước khi công bố feature — nhất là order có coupon/discount, trường hợp này code hiện **chưa xử lý** discount trong cart gửi Svea, chỉ hiển thị read-only trên trang, xem "Self-review round 5" mục Rabattkod).

- [ ] **Nội dung 2 email template** (`payment_link_send`, `payment_link_confirm`) — migration `Version20260820061500.php` đã insert sẵn 2 template này vào DB với nội dung tiếng Thuỵ Điển tối giản do code tự viết, **chưa qua ai duyệt nội dung/văn phong**. Chạy migration xong, vào **ngay** `EmailTemplateController` (admin UI có sẵn) sửa lại subject/content cho đúng chuẩn công ty **trước khi** có khách hàng thật nào nhận được — vì có sẵn migration nên lần gửi đầu tiên sẽ không còn dùng nội dung mặc định nữa nếu sửa kịp trước đó. Lưu ý quan trọng: **không được xoá `{{ link }}`** khỏi nội dung `payment_link_send` khi sửa (xem bẫy đã note ở mục "Backend — Service").

- [ ] **Nội dung trang `payment_link/terms.html.twig`** — hiện là text điều khoản tối giản tự viết (generic, chưa qua pháp lý/kinh doanh duyệt). Nên rà lại nội dung trước khi để khách hàng thật nhìn thấy.

- [ ] **Frontend React chưa làm** — repo khác, cần hoàn thành button + popup trước khi staff dùng được từ giao diện (backend API đã sẵn sàng, có thể test tay qua Postman/curl trước khi FE xong).

- [ ] **Dọn dữ liệu test** — trong lúc dev, đã từng gọi `sendPaymentLink()`/mở link thật trên **order thật #9747 (id 11338)** ở môi trường dev nhiều lần (đổi `orderStatus` thành "Send Payment Link", set `paymentLinkToken`/`paymentLinkEmail`/`paymentLinkSveaOrderId`...). Nếu đây là order thật không nên ở trạng thái này, cần tự reset lại tay trước khi coi dev DB là "sạch" (không ảnh hưởng production vì DB dev/production tách biệt, nhưng nêu ra để bạn biết).

---

## Self-review (2026-08-20) — bug tìm được sau khi đọc kỹ `vendor/sveaekonomi/checkout/README.md`

Sau khi implement xong, đọc lại README của SDK Svea thật kỹ (bảng "MerchantSettings" §11.1 và "PresetValue" §11.4) thì phát hiện **2 lỗi nghiêm trọng** khiến `createOrderFromOrder()` chắc chắn sẽ bị Svea API reject nếu không sửa, cộng thêm vài gap về idempotency/data-integrity. Đã fix hết, chi tiết:

1. 🔴 **Bug nghiêm trọng — thiếu `termsUri`/`checkoutUri` bắt buộc**: README §11.1 liệt kê `TermsUri`, `CheckoutUri`, `ConfirmationUri`, `PushUri` đều là field **bắt buộc** (`*`) trong `MerchantSettings`. Bản đầu chỉ có `confirmationUri`/`pushUri`/`webhookUri`, thiếu 2 field bắt buộc → gọi `create()` chắc chắn bị Svea trả lỗi validation, nghĩa là **toàn bộ tính năng sẽ không hoạt động** dù code không có exception PHP nào. Đã fix: thêm route `payment_link_terms` (trang điều khoản tối giản mới, vì route payment-link là standalone không gắn channel/wiki page) làm `termsUri`, và tự trỏ về chính `payment_link_checkout` làm `checkoutUri` (giống cách `checkoutUri` trỏ về `ecom_checkout` trong bản gốc cho ecom).
2. 🔴 **Bug nghiêm trọng — `presetValues` dùng typeName không tồn tại**: README §11.4 liệt kê chính xác 5 typeName được hỗ trợ: `NationalId`, `EmailAddress`, `PhoneNumber`, `PostalCode`, `IsCompany`. Bản đầu có thêm `street`/`city` (bịa ra, không có trong danh sách) — nhiều khả năng khiến request bị Svea reject hoặc silently ignore. Đã fix: bỏ `street`/`city`, chỉ giữ 3 field chắc chắn hỗ trợ (`emailAddress`, `phoneNumber`, `postalCode`). Hệ quả: mockup "Prefill with customer information" **không thể prefill đầy đủ Namn/Adress/Stad** như mockup yêu cầu — Svea Checkout API không có cơ chế prefill cho các field đó, đây là giới hạn của bên thứ 3, không phải thiếu sót code.
3. 🟠 **Bug — `clientOrderNumber` trùng lặp khi resend**: bản đầu dùng thẳng `$order->getOrderNr()` làm `clientOrderNumber`. Field này là định danh order phía Svea; resend gọi `createOrderFromOrder()` lần 2 với **cùng orderNr** — nếu Svea coi đây là unique identifier (khả năng cao, không thấy README nói rõ được phép trùng) thì lần resend sẽ lỗi. Đã fix: ghép thêm 8 ký tự đầu của token mới (`{orderNr}-{token8}`, cắt tối đa 32 ký tự theo giới hạn Svea) — đảm bảo mỗi lần gọi `create()` luôn unique.
4. 🟠 **Bug — Svea order cũ bị bỏ rơi khi resend**: chưa từng cancel Svea order cũ trước khi tạo order mới → order cũ treo vô thời hạn phía Svea. Đã fix: thêm `SveaService::cancelOrder()` (dùng `CheckoutAdminClient::cancelOrder()` có sẵn trong SDK), gọi best-effort trước khi tạo order mới trong `OrderService::sendPaymentLink()` (không chặn flow nếu cancel lỗi, chỉ log warning).
5. 🟠 **Bug data-integrity — idempotency guard dùng field mutable**: `handleSveaCallback()` bản đầu check "đã xử lý chưa" bằng `order.orderStatusId == payByLinkStatus.id`. Nếu Svea gọi push/webhook 2 lần (retry) **và** staff đã đổi status thủ công ở giữa 2 lần gọi đó (sang bước sản xuất khác chẳng hạn), guard này sai → code chạy lại lần 2: gửi trùng email confirm cho khách + ghi đè status staff vừa set tay. Đã fix: đổi sang check bằng chính comment đã ghi (chứa `payment_id: {sveaOrderId}` cụ thể của Svea order hiện tại) — ổn định hơn vì gắn với dữ liệu thực tế đã xử lý, không phụ thuộc field staff hay sửa tay.
6. 🟡 **Gap — race condition ở trang checkout**: nếu khách thanh toán xong rồi mở lại link gốc (từ email) trước khi push/webhook kịp cập nhật DB, trang `checkout()` sẽ hiện lại form thanh toán thay vì "đã thanh toán" (vì chỉ check `order.orderStatusId` nội bộ, chưa kịp update). Đã fix: `checkout()` giờ cũng check trực tiếp `Status == 'Final'` từ response `sveaService->get()` (nguồn thật từ Svea, không phụ thuộc DB đã sync hay chưa) → render `confirm.html.twig` luôn nếu vậy.
7. 🟡 **Gap — thiếu validate `payment_type_id`**: bản đầu chỉ check `isset`/`!empty`, không xác minh id đó có tồn tại thật trong `status_list` (`type = order_payment_type`) không — id rác/bịa sẽ được lưu vào `paymentLinkPaymentTypeId` rồi gán thẳng vào `order.paymentTypeId` sau khi thanh toán, có thể phá dữ liệu order (FE dropdown/report dựa trên payment type sẽ gặp id không tồn tại). Đã thêm `findOneBy` validate trước khi chấp nhận.

Toàn bộ fix đã qua `php -l`, `lint:twig`, `cache:clear`, `debug:router` lại — không phá vỡ gì đã hoạt động trước đó.

⚠️ **Rủi ro còn lại, cần QA thủ công/xác nhận với Svea trước khi go-live** (không thể tự verify từ môi trường code-only này):
1. Chưa gọi thật `checkoutClient->create()` với credentials thật để xác nhận `termsUri`/`checkoutUri` mới thêm có làm Svea chấp nhận request không (chỉ verify được qua đọc README, chưa test API call thật).
2. Chưa test end-to-end thật (tạo order → gọi API send-payment-link → mở link → thanh toán qua Svea → nhận push/webhook → kiểm tra status + email) vì thiếu FE để trigger, và Svea webhook cần URL public truy cập được từ internet (không test được từ local).
3. `clientOrderNumber` unique theo `{orderNr}-{token8}` là suy đoán phòng thủ hợp lý, nhưng chưa 100% chắc Svea thật sự yêu cầu unique — nếu Svea cho phép trùng thì fix này vô hại (chỉ thêm 1 hậu tố), nên giữ nguyên là an toàn.
4. Email gửi qua Mailchimp Transactional (`MailService::sendMail`) — chưa gửi thử email thật để xem giao diện.

---

## Self-review round 2 (2026-08-20) — thay đổi hành vi gửi email confirm

**Vấn đề phát hiện qua trao đổi thêm**: bản đầu, sau khi khách thanh toán xong, `handleSveaCallback()` chỉ gửi 1 email xác nhận tới `order.getCustomerEmail()` — tức là **luôn** gửi về email gốc trên order, bất kể staff đã gửi link thanh toán tới email nào lúc trước. Điều này có vấn đề khi staff gửi link tới 1 người khác không phải chủ order thật (vd kế toán/trợ lý mua hàng hộ) — người vừa thực hiện thanh toán (dùng email khác) sẽ không nhận được email xác nhận nào, dù họ mới là người vừa trả tiền.

**Quyết định fix**: gửi email xác nhận tới **cả 2 địa chỉ** khi khác nhau:
1. `order.customerEmail` — để chủ order chính thức biết đơn hàng của mình đã được thanh toán (dù ai trả tiền).
2. `order.paymentLinkEmail` — để người vừa thanh toán (nhận link + thực hiện checkout) có email xác nhận ngay, đúng UX chuẩn của 1 luồng thanh toán online.

Nếu 2 địa chỉ trùng nhau (trường hợp phổ biến nhất — staff không đổi email khi gửi, giữ nguyên prefill từ `order.customerEmail`) thì **chỉ gửi 1 email**, tránh spam trùng lặp.

**Thay đổi code**:
- Thêm field mới `paymentLinkEmail` (`string, nullable`) vào `Order` entity — lưu lại chính xác giá trị `email` mà staff đã dùng ở lần gửi `sendPaymentLink()` gần nhất (mỗi lần resend sẽ ghi đè giá trị mới).
- Migration `migrations/Version20260820033742.php` — `ALTER TABLE orders ADD payment_link_email VARCHAR(255)`.
- `OrderService::sendPaymentLink()` — thêm `$order->setPaymentLinkEmail($email);` ngay cạnh chỗ set `paymentLinkToken`.
- `PaymentLinkController::handleSveaCallback()` — thay 1 dòng gọi `sendPaymentLinkConfirm($order, $order->getCustomerEmail())` bằng vòng lặp gửi tới `array_unique` (case-insensitive, dùng `strtolower` làm key) của `[order.customerEmail, order.paymentLinkEmail]`, bỏ qua giá trị rỗng.
- `generateItemForList()` — expose thêm `paymentLinkEmail` cho FE (hữu ích để staff biết chính xác đã gửi cho ai).
- `generateUpdate()` — thêm `paymentLinkEmail` vào `$notUpdatedFields` (read-only từ FE, đồng bộ với 5 field payment link khác).

Đã verify: `php -l`, `cache:clear`, `doctrine:schema:validate` (không còn diff nào ở `orders`), và chạy thử `generateItem()` + `update()` guard trên 1 order thật trong DB dev — field mới trả về đúng, guard chặn ghi hoạt động đúng.

---

## Self-review round 3 (2026-08-20) — tách `sendPaymentLink()` khỏi Svea (kiến trúc quan trọng, do sếp yêu cầu sau khi test thấy lỗi 401)

**Vấn đề phát hiện khi test thật**: khi gọi thử `POST /orders/{id}/send-payment-link` trên môi trường dev, bị lỗi `400 {"message":"HTTP/1.1 401 Unauthorized"}`. Truy ra nguyên nhân: `SveaService` (code có sẵn từ trước, không phải code mới của ticket này) hardcode gọi **Svea PROD endpoint** (`Connector::PROD_BASE_URL`, dòng `TEST_BASE_URL` bị comment sẵn trong constructor), trong khi `.env` hiện đang active 1 secret gắn nhãn `###DEV###` — secret sandbox gọi lên endpoint PROD bị Svea reject 401. Đây là vấn đề **môi trường/credentials cấu hình sẵn của repo**, không phải bug logic của payment-link feature — nhưng nó lộ ra 1 vấn đề thiết kế thật: bản đầu, `OrderService::sendPaymentLink()` gọi thẳng `sveaService->createOrderFromOrder()` **ngay lúc staff bấm gửi**, nghĩa là:
- Gửi link **phụ thuộc cứng vào Svea còn sống hay không** — Svea lỗi/down là staff không gửi được link luôn, dù lẽ ra gửi email chỉ cần lưu token + gửi mail, không cần biết payment provider nào cả.
- Nếu khách **không bao giờ mở link**, vẫn đã tạo 1 Svea order "mồ côi" tồn tại vô ích bên Svea.
- `OrderService` (tầng nghiệp vụ chung, dùng cho toàn bộ order) bị **hardcode phụ thuộc 1 payment provider cụ thể** — sau này có payment method khác (không phải Svea) thì phải sửa lại `OrderService::sendPaymentLink()`, đúng ra tầng đó không nên biết gì về provider nào cả.

**Quyết định kiến trúc (theo yêu cầu trực tiếp)**: `send-payment-link` **chỉ làm đúng phần việc của nó** — validate order, generate token, lưu `paymentLinkEmail`/`paymentLinkSentAt`/`paymentLinkExpiresAt`/`paymentLinkPaymentTypeId`, đổi status, gửi email. **Không gọi Svea (hay bất kỳ payment provider nào) ở bước này.** Việc tạo phiên thanh toán thật (Svea checkout order) dời xuống **lúc khách thật sự mở trang `/payment-link/{token}`** — đúng thời điểm cần hiển thị iframe cho khách thanh toán, không sớm hơn.

**Thay đổi code**:
- `OrderService::sendPaymentLink()` — **bỏ hoàn toàn** lệnh gọi `sveaService->createOrderFromOrder()`. Method này giờ **không còn có thể fail vì lý do payment provider** — chỉ còn các lỗi liên quan validate input/order (email, payment_type_id, order not found/cancelled/completed). Vẫn giữ bước cancel Svea order cũ (best-effort) nếu order đã từng có `paymentLinkSveaOrderId` từ lần gửi/mở link trước (trường hợp resend sau khi khách đã mở link cũ) — nhưng sau cancel thì set `paymentLinkSveaOrderId = null`, không tạo order mới ngay.
- `PaymentLinkController::checkout()` — thêm nhánh: nếu `order.paymentLinkSveaOrderId` **chưa có** (lần đầu khách mở link, hoặc mở link mới sau resend) → gọi `sveaService->createOrderFromOrder($order, $token)` tạo mới, lưu `OrderId` trả về vào `paymentLinkSveaOrderId`, `flush()`; nếu **đã có** (khách refresh/mở lại đúng link đó nhiều lần) → gọi `sveaService->get()` lấy lại session cũ, **không tạo trùng**. Cả 2 nhánh dùng chung logic check `Cancelled`/`Final`/render bên dưới (response shape của `createOrderFromOrder()` và `get()` giống hệt nhau — cả 2 đều trả `['status' => ..., 'response' => [...]]` với cùng cấu trúc `Status`/`Gui.Snippet`).
- Nếu `createOrderFromOrder()` thất bại lúc khách mở link (vd do lỗi credentials như trên) → **không set/flush gì** vào `paymentLinkSveaOrderId`, khách thấy trang lỗi "Cannot load checkout" — lần load lại sau (hoặc lần khách F5) sẽ tự thử tạo lại từ đầu, không bị kẹt ở trạng thái dở dang.

**Lợi ích phụ**: tầng `OrderService` giờ hoàn toàn không biết gì về Svea — 100% provider-agnostic. Nếu sau này cần thêm payment method khác cho tính năng "share payment link" (không chỉ Svea), điểm cần sửa chỉ là `PaymentLinkController::checkout()` (chỗ quyết định tạo phiên thanh toán với provider nào), không cần đụng lại `OrderService::sendPaymentLink()` — đây chính xác là điều được yêu cầu làm rõ khi review.

**Trade-off cần biết**: 1 edge case nhỏ mới xuất hiện — nếu khách mở link ở 2 tab cùng lúc ở **lần đầu tiên** (cả 2 request đều thấy `paymentLinkSveaOrderId == null` trước khi request nào kịp `flush()`), có thể tạo ra 2 Svea order, DB chỉ lưu lại id của lần `flush()` sau cùng, 1 Svea order còn lại bị mồ côi. Xác suất cực thấp (phải trùng đúng khoảnh khắc), hậu quả không nghiêm trọng (order dữ liệu vẫn nhất quán, chỉ dư 1 session Svea không dùng tới, tự expire), nên **không** thêm cơ chế lock cho trường hợp này — nếu cần khắt khe hơn sau này có thể thêm `SELECT ... FOR UPDATE` hoặc tương tự.

**Verify**: đã chạy `sendPaymentLink()` thật trên 1 order dev — trả `200`, `paymentLinkSveaOrderId` đúng là `null` sau khi gửi (không đụng Svea), **không còn lỗi 401** ở bước gửi link nữa (lỗi 401 giờ chỉ có thể xảy ra khi khách thật sự mở trang checkout — đúng chỗ nó nên xảy ra). Vấn đề credentials PROD/DEV mismatch trong `.env`/`SveaService` **vẫn chưa fix** — đó là việc riêng, cần bạn quyết định dùng sandbox hay live credentials cho môi trường dev, không thuộc phạm vi sửa của ticket này.

---

## Self-review round 4 (2026-08-20) — tách Svea ra khỏi cả `checkout()` GET, dùng pattern AJAX giống ecom checkout

**Test thật phát hiện tiếp**: sau round 3, mở link thật (`GET /payment-link/{token}`) vẫn bị lỗi "Cannot load checkout, please contact us" — vì `checkout()` vẫn **gọi Svea trực tiếp ngay khi render trang** (chỉ dời từ `sendPaymentLink()` sang `checkout()`, chưa thực sự giải quyết việc hardcode 1 provider). Được chỉ ra: trang checkout ecom gốc (`AppController::checkout()` + `templates/webshop/checkout.html.twig`) **không làm vậy** — nó render form với payment method là **radio chọn được** (`svea`/`swish`), và **chỉ khi khách đổi radio** (`change` event) thì JS mới gọi AJAX riêng (`ecom_checkout_svea_create_order` → `AppController::sveaGenerateOrder()`) để tạo Svea order và nhúng iframe vào — route GET trang checkout chính nó không đụng Svea bao giờ.

**Đối chiếu thực tế**: đọc code `checkout.html.twig` xác nhận `swish` hiện đang bị **comment hết** (dead/disabled), chỉ còn `svea` hoạt động — khớp với mockup `mockup-payment-link-checkout.png` (chỉ có 1 radio "Svea payment", card/Swish/Trustly nằm trong iframe Svea chứ không phải lựa chọn tách riêng). Nên payment-link page hiện chỉ cần 1 radio, nhưng code phải **cấu trúc theo đúng pattern AJAX-trên-selection** để sau này thêm method thứ 2 chỉ cần thêm radio + thêm 1 action AJAX, không phải sửa lại `checkout()`.

**Thay đổi code**:
- `PaymentLinkController::checkout()` — bỏ hoàn toàn tham số `SveaService`, không còn dòng nào gọi Svea. Chỉ validate token/expiry/status (tách logic dùng chung này ra `guardPaymentLinkOrder()` — dùng lại ở cả `checkout()` lẫn action AJAX mới) rồi render `checkout.html.twig`, **không truyền `html` snippet nữa**.
- Thêm action mới `sveaGenerateOrder($token, ...)` — mirror `AppController::sveaGenerateOrder()`: check `$request->isXmlHttpRequest()`, validate lại order qua `guardPaymentLinkOrder()`, rồi mới gọi `sveaService->createOrderFromOrder()` (lần đầu) hoặc `get()` (đã có sẵn `paymentLinkSveaOrderId`), trả JSON `{html: <rendered snippet>}` hoặc `{message: ...}` lỗi `400`. Có thêm check `Status == 'Final'` (khách reload sau khi đã trả tiền — trả message riêng thay vì snippet, vì response shape của action AJAX chỉ là JSON, không redirect được như route GET).
- Route mới `payment_link_svea_generate_order` — `POST /payment-link/{token}/svea-generate-order`.
- `templates/payment_link/checkout.html.twig` — thêm section "Betalning" với radio `svea`, div `#svea-checkout-iframe` **rỗng** ban đầu, và JS (jQuery — **phải tự include** `webshop/js/jquery-3.5.1.min.js` vì template extends `base.html.twig` trực tiếp chứ không qua `ecom.html.twig`, nơi vốn đã load sẵn jQuery) mirror `loadSvea()` của `checkout.html.twig` gốc — chỉ gọi AJAX khi radio `.paymentMethod` đổi (`change` event). *(Bản đầu round 4 có set `checked` mặc định + tự gọi AJAX lúc `$(document).ready()` — đã bỏ ở "Self-review round 5" bên dưới theo yêu cầu, khớp đúng hành vi trang ecom gốc: không method nào được chọn sẵn.)*

**Verify bằng HTTP request thật** (`curl` tới domain local `ts_new_order_system.loc` bạn cung cấp):
- `GET /payment-link/{token}` → **200**, trang render đầy đủ (sản phẩm, radio "Svea payment", iframe rỗng, jQuery + JS đúng) — không còn trang lỗi.
- `POST /payment-link/{token}/svea-generate-order` (giả lập `X-Requested-With: XMLHttpRequest`) → `400 {"message":"HTTP/1.1 401 Unauthorized"}` — **cùng lỗi credentials Svea đã biết từ trước**, nhưng giờ chỉ làm hỏng phần iframe (JS hiện alert lỗi ngay trong khung, phần còn lại trang vẫn dùng được), không làm sập cả trang.

Vấn đề credentials Svea PROD/DEV mismatch (`.env`/`SveaService`) **vẫn chưa fix** — việc riêng của môi trường, chưa thuộc scope sửa của ticket.

---

## Self-review round 5 (2026-08-20) — UI refinements sau khi test tay trên môi trường thật

Loạt thay đổi nhỏ dựa trên phản hồi trực tiếp khi bạn test trang thật (`http://ts_new_order_system.loc/payment-link/{token}`), không phát sinh bug logic mới — chỉ chỉnh UI/UX cho khớp yêu cầu:

1. **Bỏ hẳn `sveaService->cancelOrder()` khỏi `OrderService::sendPaymentLink()`** (theo yêu cầu trực tiếp "bỏ cái vụ SVEA đi") — trước đó (round 3) method này vẫn còn 1 chỗ gọi `sveaService->cancelOrder()` best-effort để dọn Svea order cũ lúc resend. Giờ bỏ hẳn — `OrderService::sendPaymentLink()` **không còn dòng nào gọi `SveaService`** (chỉ còn field `paymentLinkSveaOrderId` bị set về `null`, đó là field DB thường, không phải call). Hệ quả: Svea order cũ (nếu khách đã từng mở link trước khi resend) sẽ **không bị chủ động cancel nữa**, cứ để tự expire bên Svea theo thời gian — đơn giản hoá, chấp nhận đánh đổi không dọn dẹp chủ động.

2. **Thêm block "Kunduppgifter" (thông tin khách hàng) — luôn hiện, đọc thẳng từ order**: phát hiện ra trang chưa từng render phần này (nhầm tưởng Svea `presetValues` sẽ tự hiển thị trong iframe — nhưng Svea không hỗ trợ prefill tên/địa chỉ như đã note ở "Self-review round 1"). Giờ hiển thị trực tiếp trên trang mình, **đọc thẳng từ `order.customerName/Address/PostCode/City/Country/Email/Mobile`** — hoàn toàn độc lập với payment method hay Svea có load được hay không.

3. **Thêm "Rabattkod" — chỉ hiển thị read-only, không có input**: theo đúng tinh thần "trang này chỉ để pay, không đổi gì" — chỉ show nếu `order.couponCode` đã có sẵn từ trước (kèm `order.discount` nếu có), không cho khách nhập/áp mã mới (order đã chốt tổng tiền, áp coupon mới sẽ phải tính lại toàn bộ order — ngoài scope).

4. **Bỏ qua "Eventuellt meddelande om din order"**: field này ở ecom checkout map thẳng vào `order.comment` — nhưng field đó cũng đang bị dùng để ghi log nội bộ (`payment_id`, `date_paid`, "Payment link sent to..."). Hiển thị thẳng cho khách xem sẽ **lộ note nội bộ** không dành cho khách đọc → quyết định bỏ qua, không có field nào khác an toàn để thay thế.

5. **Thêm logo Svea** (`webshop/images/svea.jpg`) cạnh label radio "Svea payment" — đúng y hệt markup ở `checkout.html.twig` gốc.

6. **Bỏ `checked` mặc định trên radio + bỏ auto-gọi `loadSvea()` lúc `$(document).ready()`**: bản round 4 có để `checked` sẵn (vì nghĩ "chỉ có 1 lựa chọn nên chọn sẵn cho tiện") + gọi AJAX ngay khi trang load. Bị chỉ ra là sai — phải khớp *y hệt* hành vi trang ecom gốc: **không method nào được chọn sẵn**, JS chỉ chạy khi có sự kiện `change` thật sự từ user. Đã sửa: bỏ `checked`, bỏ dòng gọi `loadSvea()` trong `$(document).ready()`, chỉ giữ lại binding `on('change', '.paymentMethod', loadSvea)`.

7. **Thêm heading "Svea Kassa"** phía trên block iframe (tiếng Thuỵ Điển — repo chưa có bản dịch sẵn cho "Svea checkout" ở bất kỳ file `translations/*.sv.yaml` nào, kể cả trang ecom gốc cũng chỉ hiển thị key tiếng Anh chưa dịch; chọn "Svea Kassa" để nhất quán với "Kassa" đã dùng làm tiêu đề trang).

8. **Ẩn hẳn block "Svea Kassa" (heading + iframe) mặc định** (`class="show-svea" style="display: none;"`, đúng pattern `show-svea`/`not-show-svea` toggle của trang ecom gốc) — chỉ `.show()` bằng JS khi radio Svea được chọn, `.hide()` khi bỏ chọn/chọn method khác.

9. **Nâng cấp phần header trang** — thay `<h1>Kassa</h1>` đơn giản bằng layout 2 cột: logo `public/logo-tshirt.png` bên trái, bên phải là tiêu đề "Kassa" + thông tin order (`order.orderNr`, `order.dateOrder` định dạng `Y-m-d`, `order.totalSumInclTax` — tổng tiền cần thanh toán).

**Lưu ý về testing**: trong quá trình làm việc này, cả tôi và bạn cùng test song song trên **cùng 1 order** (#9747/order id 11338) — mỗi lần gọi `send-payment-link` (dù tôi hay bạn gọi) đều generate token mới, vô hiệu hoá token cũ ngay lập tức. Đã xảy ra việc tôi vô tình đè token bạn đang test 2 lần. Từ giờ tôi không tự ý gọi `sendPaymentLink()`/mở link/gọi AJAX trên order thật nữa — chỉ sửa code, để bạn tự test.

Tất cả thay đổi đã qua `lint:twig` + `cache:clear`, không chạy live test (theo yêu cầu).

---

## Self-review round 6 (2026-08-20) — gộp logic "order có được gửi/pay payment link không" thành 1 method dùng chung + fix lại đúng yêu cầu status_list

**Yêu cầu**: khi trả order detail, cần thêm 1 field cho biết order có valid để gửi payment link không, và logic check nên viết thành 1 function dùng chung được — kể cả lúc load trang payment.

**Thêm `OrderService::canSendPaymentLink(Order $order): bool`** (public) — gộp đúng rule đã có sẵn (`!isCancel() && orderStatusId != id status Komplett`) thành 1 method, dùng lại ở 3 chỗ thay vì rải rác:
1. `generateItemForList()` — thêm field mới `canSendPaymentLink` vào response (áp dụng mọi endpoint trả order: `GET /orders/{id}`, list orders). FE giờ chỉ cần check đúng field này, không phải tự combine `cancel` + `isCompleted`.
2. `sendPaymentLink()` — thay check inline bằng gọi `canSendPaymentLink($order)`.
3. **Mới — `PaymentLinkController::guardPaymentLinkOrder()`**: trước giờ trang checkout (`checkout()`/`sveaGenerateOrder()`) **chưa từng check lại** cancel/completed sau khi link đã gửi — nếu order bị cancel *sau khi* gửi link nhưng *trước khi* khách mở/thanh toán, khách vẫn thanh toán được bình thường (gap thật). Giờ check lại cùng 1 rule ở đây, chặn với message "This order is no longer valid for payment, please contact us" nếu không còn hợp lệ. Cần inject thêm `OrderService` vào `PaymentLinkController` (pattern đã có sẵn ở `AppController`, không phải điều gì mới).

**Fix lại đúng yêu cầu status_list (bị lệch từ đầu)**: `canSendPaymentLink()` bản đầu (và field `isCompleted` viết từ trước đó) dùng `$this->parameterBag->get('order_status_complete_id')` — param tĩnh trong `config/services.yaml` (hiện = 22), **không phải** query `status_list` như yêu cầu gốc đã nêu rõ ngay từ đầu ("cái mà order completed status, phải dùng table status_list với unique key = order_status_complete"). Lý do tôi lệch lúc đó: thấy param này đã tồn tại sẵn và được dùng ở `OrderService::listOrder()` nên tự quyết định tái sử dụng cho gọn — **tự ý đổi hướng mà không hỏi lại**, bị nhắc mới sửa. Đã fix:
- Thêm `OrderService::getCompleteStatusId(): ?int` (private) — query thật `status_list` (`type = order_status`, `uniqueKey = order_status_complete`, `dateDeleted = null`), **cache lại trên instance** (property `$completeStatusId`) để tránh N+1 query khi `generateItemForList()` chạy lặp lại cho danh sách nhiều order.
- `canSendPaymentLink()` và field `isCompleted` đều đổi sang gọi `getCompleteStatusId()` thay vì param.
- **Chỉ sửa 2 chỗ tôi tự viết cho ticket này** (`isCompleted`, `canSendPaymentLink`) — **không đụng** các chỗ khác trong `OrderService.php` đang dùng `order_status_complete_id` (vd `listOrder()`, `changeOrderProductTypeStatus()`...) vì đó là code có sẵn từ trước, không thuộc scope ticket, sửa vào có thể ảnh hưởng hot path khác không cần thiết.

Đã `php -l` + `cache:clear` — sạch, không chạy live test.

---

## Self-review round 7 (2026-08-20) — chuyển Svea test/live sang env var, tránh quên khi deploy

**Vấn đề**: `SveaService` (code có sẵn từ trước, không thuộc ticket) chọn base URL Svea (PROD hay TEST) bằng cách **comment/uncomment code tay** — rất dễ quên đổi lại trước khi deploy, dẫn tới rủi ro live chạy nhầm sandbox hoặc dev chạy nhầm production. Đây là nguyên nhân gốc của toàn bộ lỗi 401 gặp phải khi test payment-link (secret DEV trong `.env` đang gọi nhầm lên PROD endpoint).

**Fix — chuyển sang đọc biến môi trường**:
- `config/services.yaml`: thêm param mới ngay cạnh 2 param Svea có sẵn (`svea_merchant_id`/`svea_merchant_secret`):
  ```yaml
  parameters:
      # ...
      svea_merchant_id: '%env(SVEA_MERCHANT_ID)%'
      svea_merchant_secret: '%env(SVEA_MERCHANT_SECRET)%'
      # Defaults to false (Svea live endpoint) via .env — override per-machine in .env.local, never here.
      svea_use_test_environment: '%env(bool:SVEA_USE_TEST)%'
  ```
  `%env(bool:SVEA_USE_TEST)%` — Symfony tự cast giá trị string từ biến môi trường `SVEA_USE_TEST` (`.env`/`.env.local`) sang `bool` thật khi resolve param, không cần tự parse tay trong code.
- `.env` (file **commit**, dùng chung mọi máy/server): thêm `SVEA_USE_TEST=0` — mặc định **luôn là Svea live**, an toàn cho production kể cả khi không ai đụng gì.
- `.env.local` (file **mới tạo, không commit** — đã nằm sẵn trong `.gitignore` dòng `/.env.local`): set `SVEA_USE_TEST=1` **chỉ trên máy dev này** — khớp với secret `###DEV###` đang active sẵn trong `.env`.
- `SveaService` constructor: thay 2 dòng hardcode `Connector::PROD_BASE_URL`/`Connector::TEST_BASE_URL` (1 dòng active, 1 dòng comment) bằng `if ($parameterBag->get('svea_use_test_environment')) { ...TEST... } else { ...PROD... }`.

**Verify** (qua `ParameterBagInterface`/reflection trên `SveaService`, không gọi Svea thật):
- `svea_use_test_environment` resolve đúng `true` trên máy dev (nhờ `.env.local`).
- `SveaService::$baseUrl` = `https://checkoutapistage.svea.com` (Svea sandbox — đúng), `$baseUrlAdmin` = `https://paymentadminapistage.svea.com`.
- Production (không có `.env.local`) sẽ luôn resolve `false` → `https://checkoutapi.svea.com` (live) — đúng mặc định an toàn.

**Lợi ích thật (ngoài an toàn deploy)**: nếu secret `###DEV###` trong `.env` đúng là secret sandbox Svea hợp lệ (nhiều khả năng), fix này **tự giải quyết luôn lỗi 401** gặp suốt quá trình test trước đó — vì trước giờ secret sandbox bị gọi nhầm lên endpoint PROD.

⚠️ Đây là thay đổi trên code **dùng chung** (`SveaService` — cả ecom checkout lẫn payment-link đều dùng class này), không phải code riêng của ticket, nhưng bắt buộc phải sửa vì là nguyên nhân chặn toàn bộ việc test payment-link. Không ảnh hưởng hành vi hiện tại của ecom checkout trên production (mặc định vẫn PROD y hệt trước khi sửa).

---

## Self-review round 8 (2026-08-20) — bug thật khi test AJAX: `Name` vượt giới hạn 40 ký tự của Svea

**Lỗi gặp lúc test thật** (`POST /payment-link/{token}/svea-generate-order`): `The field Name must be a string or array type with a maximum length of '40'.` — message gốc từ Svea, đúng theo giới hạn README §11.3 OrderRow: `Name` bắt buộc 1-40 ký tự.

**Nguyên nhân**: `SveaService::createOrderFromOrder()` gửi thẳng `$item->getProductName()` làm `name` cho từng dòng cart, **không cắt giới hạn**. Query thật trên DB xác nhận có rất nhiều sản phẩm tên dài hơn 40 ký tự (dài nhất tìm thấy: 119 ký tự, vd `"Extra kostnad plagg fr L157 pga arbetsförhållanden i fabriker  - skänks till https://fairaction.se/om-oss/organisation/"`) — bug này sẽ gặp thường xuyên trên order thật, không phải case hiếm.

**Fix**: `"name" => mb_substr($item->getProductName(), 0, 40)` — dùng `mb_substr` (không phải `substr`) để cắt đúng theo ký tự, an toàn với chữ Thuỵ Điển (å/ä/ö là multi-byte trong UTF-8, `substr` thường có thể cắt giữa 1 ký tự làm hỏng encoding).

**Chỉ sửa đúng 1 chỗ** trong `createOrderFromOrder()` (method mới của ticket này) — **không đụng** `SveaService::createOrder()` (method gốc dùng cho ecom checkout), dù về lý thuyết cùng bug này nhiều khả năng cũng tồn tại ở đó (ecom cũng gửi thẳng `$item->getProductName()` không cắt). Đây là code có sẵn, ngoài scope ticket — nếu muốn fix luôn bên ecom thì cần yêu cầu riêng.

Đã `php -l` + `cache:clear` — sạch.

---

## Self-review round 9 (2026-08-20) — bug tiền thật: Svea hiển thị sai tổng tiền (thiếu VAT)

**Lỗi gặp lúc test thật**: order #9747 có `totalSumInclTax = 277.75 kr` (giá đúng khách phải trả), nhưng Svea checkout lại hiển thị **222.20 kr**.

**Nguyên nhân**: README của Svea (Get Order response, OrderRow) ghi rõ **`UnitPrice` phải là giá đã gồm VAT** ("Price of the product including VAT"). Nhưng `createOrderFromOrder()` gửi thẳng `$item->getPrice()` (159.2, giá **chưa** VAT — khớp đúng `totalSum` excl-tax = 222.2 của order = 159.2 + shippingFee 63) và `$order->getShippingFee()` (63, cũng chưa VAT) làm `unitPrice`, không nhân thêm VAT trước khi gửi. Verify: `159.2 × 1.25 = 199.0`, `63 × 1.25 = 78.75`, tổng `277.75` — khớp chính xác `totalSumInclTax` thật của order.

**Fix**:
- Shipping fee: đổi `$order->getShippingFee()` → `$order->getShippingFeeInclTax($taxRate)` (method có sẵn trên `Order` entity, chỉ chưa được dùng ở chỗ này).
- Item price: `Order`/`OrderProduct` không có sẵn getter incl-tax cho từng item → tự tính `$item->getPrice() * (1 + $taxRate)`.
- Cả 2 chỗ đều `round(... * 100)` trước khi gửi Svea (basis point phải là số nguyên).
- Verify bằng script PHP độc lập (không gọi Svea thật): `199.0 + 78.75 = 277.75` — khớp `totalSumInclTax`.

⚠️ **Phát hiện quan trọng — cùng bug này đang tồn tại ở `SveaService::createOrder()` (method gốc, đang dùng cho ecom checkout LIVE)**: đọc lại code thấy `$ecomOrder->getShippingFee() * 100` và `$item->getPrice() * 100` cũng gửi thẳng giá chưa VAT y hệt — nhiều khả năng khách hàng ecom thật đang thấy Svea hiển thị **thấp hơn** số tiền thực phải trả. Đáng chú ý: dòng "discount" ngay cạnh đó dùng biến `$discountInclTax` (đã cộng VAT đúng) — người viết code có ý thức về incl/excl tax, chỉ bỏ sót ở shipping fee + item price. **Đã hỏi sếp — quyết định KHÔNG fix trong ticket này**, sếp tự xử lý/tạo ticket riêng cho phần ecom vì đây là code live ảnh hưởng tiền thật, cần tự verify kỹ trước khi đổi.

Đã `php -l` + `cache:clear` + verify tính toán bằng script độc lập — sạch, không chạy live test lên Svea thật (biết trước sẽ vẫn vướng vấn đề credentials khác).

---

## Self-review round 10 (2026-08-20) — tax rate phải đọc theo từng item, không dùng config chung

**Vấn đề chỉ ra**: fix VAT ở round 9 dùng chung 1 tax rate (`tax_default` config) cho mọi item — nhưng thực tế **mỗi `OrderProduct` có tax rate riêng** (`productTaxCode`, set từ `Product::getTaxCode()` lúc tạo order — vd sản phẩm được áp thuế suất khác nhau: 25%/12%/6%/0% theo luật thuế Thuỵ Điển), không phải lúc nào cũng bằng config chung.

**Tìm thấy pattern chuẩn đã có sẵn** ở `OrderService::updateSum()`/`getTaxDescription()` (dùng để tính tổng tiền thật hiển thị trên order):
```php
$itemTotalInclTax = $itemTotalExclTax + ($itemTotalExclTax * ($item->getProductTaxCode() / 100));
```
— `productTaxCode` đã là **số phần trăm** (vd `25`, không phải `0.25`), riêng từng `OrderProduct`. Cho shipping fee thì dùng tax rate **cấp order** (`order.taxDefault`, fallback về config chung nếu order không set riêng) — cũng đúng theo pattern có sẵn ở 2 method trên, không phải per-item.

**Fix** — cập nhật `SveaService::createOrderFromOrder()`:
- Item: `unitPrice = round($item->getPrice() * (1 + $item->getProductTaxCode() / 100) * 100)`, `vatPercent = $item->getProductTaxCode() * 100` — đọc tax rate của **từng item riêng**, không còn dùng biến `$taxRate` dùng chung nữa.
- Shipping: `$shippingTaxRate = !empty($order->getTaxDefault()) ? $order->getTaxDefault() : $this->parameterBag->get('tax_default')` — đúng y hệt cách `updateSum()` resolve tax rate cho shipping.

**Verify**: order #9747 có `productTaxCode = 25` (khớp config chung nên số liệu không đổi so với round 9 — `199.0 + 78.75 = 277.75`, đúng `totalSumInclTax`) — nhưng logic giờ đã đúng cho trường hợp tổng quát: nếu 1 order có sản phẩm tax rate khác config chung (12%/6%/0%), tổng tính ra Svea vẫn sẽ đúng thay vì áp nhầm 1 rate cho tất cả.

Đã `php -l` + `cache:clear` + verify script độc lập — sạch.

⚠️ **Lưu ý khi test lại**: nếu order test đã từng tạo Svea order **trước khi có các fix round 9/10** (kiểm tra `order.paymentLinkSveaOrderId` đã có giá trị chưa), `sveaGenerateOrder()` sẽ **tái sử dụng lại session Svea cũ** (gọi `get()`, không tạo mới) — số tiền hiển thị vẫn là số **sai từ trước khi fix**, không tự động cập nhật. Cần resend link (tự reset `paymentLinkSveaOrderId` về `null`) hoặc xoá tay field này trong DB để lần mở link tiếp theo tạo Svea order mới với code đã fix.

---

## Self-review round 11 (2026-08-20) — thiếu dòng discount trong cart gửi Svea

**Vấn đề chỉ ra**: `createOrderFromOrder()` (round 9/10 mới sửa VAT/tax rate) **chưa hề gửi discount** cho Svea — trong khi `SveaService::createOrder()` (bản gốc, ecom) có hẳn 1 dòng cart item riêng cho discount:
```php
$discountInclTax = (float) $ecomOrder->getCouponDiscount();
if ($discountInclTax != 0) {
    $itemsData[] = [
        "articleNumber" => 'discount', "name" => 'Discount incl tax',
        "quantity" => 1 * 100, "unitPrice" => $discountInclTax * 100 * (-1),
        "discountPercent" => 0, "vatPercent" => ..., "unit" => "st",
    ];
}
```
Hệ quả nếu bỏ sót: order nào có `paymentLinkPaymentTypeId`/coupon áp sẵn (`order.discount != 0`) thì tổng Svea yêu cầu khách trả sẽ **cao hơn** `totalSumInclTax` thật — thiếu đúng phần discount. Order #9747 đang test có `discount = 0` nên chưa lộ ra bug này qua test tay, chỉ phát hiện qua rà code.

**Fix**: thêm 1 dòng cart `articleNumber: 'discount'` ngay sau shipping fee, dùng `$order->getDiscount()` (đã tax-inclusive sẵn, đúng convention `OrderService::updateSum()` — `$orderStatsDiscountInclTax = $order->getDiscount()`), `unitPrice` âm (`* -1`), `vatPercent` dùng `$shippingTaxRate` (tax rate cấp order, không phải per-item vì discount là điều chỉnh tổng, không gắn với 1 sản phẩm cụ thể) — giống hệt cách ecom `createOrder()` đặt dòng discount.

**Verify** (script độc lập, không gọi Svea thật): giả lập order có `discount = 20kr` (đã gồm VAT) → `199.0 (item) + 78.75 (shipping) - 20 (discount) = 257.75 kr` — đúng bằng `277.75 - 20`.

Đã `php -l` + `cache:clear` — sạch. Lưu ý test lại thật vẫn cần đảm bảo tạo Svea order **mới** (xem cảnh báo cuối "Self-review round 10").

---

## Luồng hoạt động

1. Staff mở order detail → click button **"Send payment"** (đặt cạnh nút "Redigera", cùng hàng với `Order: {orderNr}` — xem `files/mockup-order-detail-send-button.png`)
2. **Popup/dropdown mở ra → hiển thị email khách hàng (prefill từ `order.customerEmail`) để staff verify trước khi gửi, có thể sửa lại hoặc thêm CC → submit**
3. Hệ thống generate token bảo mật, gửi email tới địa chỉ đã verify, kèm link thanh toán — **bước này chưa tạo phiên thanh toán Svea** (xem "Self-review round 3")
4. Khách click link → vào trang checkout riêng (`/payment-link/{token}`) với sản phẩm từ order đó (xem `files/mockup-payment-link-checkout.png`) — **đúng lúc này** hệ thống mới tạo Svea checkout order (lần đầu mở) hoặc lấy lại session cũ (mở lại link)
5. Khi gửi link: thêm comment vào order + đổi status → **"Send Payment Link"**
6. Khi khách thanh toán xong: thêm comment `payment_id` + `date_paid` vào order, gửi email xác nhận + đổi status → **"Pay By Link"**

---

## Chi tiết theo mockup

### Mockup 1 — Order detail page (`files/mockup-order-detail-send-button.png`)

- **Vị trí nút**: nút **"Send payment"** đặt ở header của order detail, ngay cạnh nút **"Redigera"** (cùng hàng với tiêu đề `Order: {orderNr}`) — không phải ở thanh action phía trên (Avbryt order / Skicka via e-post / Offert...).
- **Dropdown xác nhận email trước khi gửi**: annotation "Dropdown to verify email before send" → khi bấm "Send payment", hiển thị dropdown/popup cho staff xác nhận lại email khách hàng (prefill sẵn từ order) trước khi thực sự gửi — tránh gửi nhầm email.
- **Đổi status**: annotation "Change status" trỏ vào dropdown `Orderstatus` hiện tại — xác nhận flow đổi status dùng đúng field/dropdown này.
- **Comment sau khi thanh toán**: annotation "Add info here after paid success" trỏ vào field `Kommentar` — mockup cho thấy comment nên có format cụ thể:
  ```
  payment_id: {sveaOrderId hoặc Svea payment reference}

  date_paid: {ISO datetime, vd 2026-05-06T21:57:58.000+00:00}
  ```
  → cập nhật lại phần `sveaPush`/`sveaWebhook` để prepend comment theo đúng format 2 dòng này thay vì câu văn tự do.

### Mockup 2 — Payment link checkout page (`files/mockup-payment-link-checkout.png`)

- **"Show products in order"**: giữ nguyên bảng sản phẩm (Dina Produkter) — đã có trong plan (`SveaService::createOrderFromOrder`).
- **Ẩn phần chọn vận chuyển (Leverans)**: mockup gạch chéo (X) toàn bộ section "Leverans" (chọn Frakt postnord / Hemleverans) → trang checkout payment-link **không cho khách chọn lại phương thức vận chuyển**, chỉ hiển thị phí ship đã cố định sẵn từ order (`order.shippingFee`), ẩn UI chọn dịch vụ.
- **"Prefill with customer information"**: toàn bộ block "Kunduppgifter" (Namn, Efternamn, Adress, Postkod, Stad, Land, Email, Mobil) cần **prefill sẵn** từ dữ liệu order, không để trống cho khách tự nhập lại:
  - Namn/Efternamn ← `order.getCustomerName()`
  - Adress ← `order.getCustomerAddress()`
  - Postkod ← `order.getCustomerPostCode()`
  - Stad ← `order.getCustomerCity()`
  - Land ← `order.getCustomerCountry()`
  - Email ← `order.getCustomerEmail()`
  - Mobil ← `order.getCustomerMobile()`

  > ⚠️ **Đã xác nhận qua self-review (đọc README chính thức của Svea SDK)**: Svea Checkout API **chỉ hỗ trợ prefill** `EmailAddress`, `PhoneNumber`, `PostalCode`, `NationalId`, `IsCompany` — **không có cơ chế prefill Namn/Adress/Stad/Land**. Đây là giới hạn thật của bên thứ 3 Svea, không phải gap trong implementation. Nếu cần khách không phải gõ lại tên/địa chỉ, cách duy nhất là tự làm 1 form riêng trước iframe Svea (ngoài scope hiện tại) — không khả thi trong 1 sprint, đề xuất giữ nguyên giới hạn này và thông báo lại cho sếp/Liem biết mockup không thể áp dụng 100%.

  → cần mở rộng `presetValues` trong `SveaService::createOrderFromOrder()` (doc gốc mới chỉ có `emailAddress` + `postalCode`, cần bổ sung thêm các field trên nếu Svea checkout API hỗ trợ).

---

## Questions for manager — Answered

| # | Câu hỏi | Trả lời của sếp | Ghi chú kỹ thuật |
|---|---------|-----------------|-----------------|
| 1 | Which orders can send a payment link? | **Hide for completed + cancel** | Ẩn button nếu `cancel = true` OR order đang ở status "Komplett" — status này tra qua `status_list` với `uniqueKey = 'order_status_complete'`, `type = StatusList::TYPE_ORDER_STATUS` (query DB thật qua `OrderService::getCompleteStatusId()`, **không** dùng param `order_status_complete_id` trong `config/services.yaml` — dù param đó cũng trỏ tới cùng status này và được dùng ở chỗ khác trong `OrderService::listOrder`, nhưng theo yêu cầu tường minh: mọi logic mới cho payment-link phải đọc thật từ `status_list`, không phụ thuộc giá trị cache tĩnh có thể lệch DB. Xem "Self-review round 6") |
| 2 | Can a payment link be resent? | **Yes, need to be able to resend** | Mỗi lần resend → generate token mới, Svea order mới, expiry mới |
| 3 | Does payment type matter? | **Send on all. After paid → change type to the selected one** | ⚠️ Còn mơ hồ — xem mục "Pending Clarification" bên dưới |
| 4 | What about ecom orders? | **Not needed to create prepaid, but ok if it's there** | Không loại trừ ecom orders, feature hoạt động cho tất cả |
| 5 | Does the payment link expire? | **Set expire day to 30 days** | `paymentLinkExpiresAt = sentAt + 30 days` |
| 6 | Does the channel need ecom enabled? | **No settings needed. Just send link, open in checkout. Maybe create new page for only checkout** | Route độc lập `/payment-link/{token}`, không cần channel slug, không cần `showOnEcom = true` |

---

## Q3 — Resolved

Nguồn: chat gốc với Liem (14/05/2026) — câu trả lời cuối "**Do what is best for it**" → Liem giao quyền quyết định chi tiết implementation.

**Quyết định: Option A** — Trong popup gửi link, staff chọn `paymentTypeId` (dropdown), giá trị này được lưu vào `paymentLinkPaymentTypeId` và gán vào `order.paymentTypeId` sau khi Svea xác nhận thanh toán thành công (status `Final`).

Lý do chọn Option A thay vì Option B (tự map từ Svea response): đơn giản hơn, staff có thể biết trước/kiểm soát được payment type sẽ hiển thị trên order sau khi thanh toán (phục vụ báo cáo/kế toán), và không phụ thuộc vào việc map chính xác các phương thức thanh toán con trong Svea (card, invoice, installment...) sang `paymentTypeId` nội bộ.

---

## Architecture quyết định (từ câu trả lời của sếp)

- **Trang checkout độc lập**: Route `/payment-link/{token}` — không nằm trong `/ecom/{channelEcomId}/`, không có menu/navigation webshop, chỉ hiển thị Svea iframe đơn giản.
- **Không cần channel settings**: Svea credentials dùng global từ `.env` (`SVEA_MERCHANT_ID`, `SVEA_MERCHANT_SECRET`), không phụ thuộc `showOnEcom` của channel.
- **Token-based**: Mọi URL (checkout, push, confirm) đều dùng `{token}`, không dùng `channelEcomId`.
- **Resend**: Mỗi lần gửi lại đều tạo token mới. Token cũ tự nhiên vô hiệu. Svea order mới **không** tạo ngay lúc resend — chỉ tạo khi khách mở link mới (xem "Self-review round 3"); Svea order cũ (nếu khách đã từng mở link trước đó) được cancel best-effort ngay lúc resend.
- **`send-payment-link` không phụ thuộc payment provider** (bổ sung sau "Self-review round 3"): việc tạo phiên thanh toán Svea hoàn toàn nằm ở `PaymentLinkController::checkout()`, không nằm ở `OrderService::sendPaymentLink()` — đây là seam để sau này thêm payment method khác chỉ cần sửa `checkout()`, không phải sửa lại tầng OrderService.

---

## API Contract — cho Frontend

> Backend đã implement + test xong (xem "Implementation Status"/"Self-review" ở trên). Mục này liệt kê đầy đủ **endpoint mới** + **field mới trên Order object** để FE làm việc độc lập, không cần đọc code BE.

### 1. Endpoint mới: `POST /api/v1/orders/{id}/send-payment-link`

Dùng cho cả **gửi lần đầu** và **resend** (cùng 1 endpoint — gọi lại y hệt, BE tự tạo token/Svea order mới mỗi lần gọi).

**Auth**: Bearer token — giống mọi endpoint order khác (`Authorization: Bearer {token}`).

**Request body (JSON)**:

| Field | Type | Required | Ghi chú |
|---|---|---|---|
| `email` | string | ✅ | Email người nhận link, phải là email hợp lệ. Nên **prefill sẵn** từ `order.customerEmail` trong popup để staff verify (theo mockup), nhưng field này FE gửi lên là email cuối cùng staff xác nhận — không nhất thiết phải trùng `order.customerEmail`. ⚠️ **BE chỉ gửi mail tới đúng địa chỉ này** (+ `email_copy` nếu có), **không** tự động cộng thêm `order.customerEmail` vào danh sách nhận — nếu staff sửa email trong popup, `order.customerEmail` gốc sẽ không nhận được gì cả |
| `email_copy` | string | optional | Email CC. Nếu có phải hợp lệ, nếu không hợp lệ → lỗi 400 |
| `email_content` | string | optional | Nội dung email tuỳ chỉnh. ⚠️ **Xem "Lưu ý quan trọng cho FE" bên dưới** — nếu gửi field này (non-empty) mà không chứa `{{ link }}`, khách sẽ **không nhận được link thanh toán trong email** |
| `payment_type_id` | integer | ✅ | Id của `status_list` (`type = order_payment_type`) — payment type sẽ tự động gán vào order **sau khi khách thanh toán xong**. BE có validate id này tồn tại thật, không phải để trống/số bừa |
| `user_login_email` | string | optional | Email của staff đang đăng nhập (dùng làm `Reply-To` header) — cùng convention với endpoint `send-mail` hiện có, không bắt buộc nhưng nên gửi để khách reply đúng người |

**Response thành công — `200`**:

Trả về **toàn bộ order object** (cùng shape với `GET /api/v1/orders/{id}`), đã cập nhật `paymentLinkToken`/`paymentLinkSentAt`/`paymentLinkExpiresAt`/`orderStatus` mới nhất — FE dùng response này để update UI ngay, không cần gọi lại GET order.

```json
{
  "id": 63066,
  "orderNr": "120960",
  "orderStatus": "Send Payment Link",
  "orderStatusId": 854,
  "paymentLinkToken": "3f9a1c...(64 hex chars)",
  "paymentLinkEmail": "staff-entered@example.com",
  "paymentLinkSentAt": "2026-08-20T10:15:00+00:00",
  "paymentLinkExpiresAt": "2026-09-19T10:15:00+00:00",
  "paymentLinkPaymentTypeId": 63,
  "isCompleted": false,
  "...": "toàn bộ field khác giống GET /api/v1/orders/{id}"
}
```

**Response lỗi** — luôn dạng `{ "message": "..." }`, các trường hợp có thể gặp (theo đúng thứ tự BE check, dừng ở lỗi đầu tiên gặp phải):

| Status | Message | Khi nào |
|---|---|---|
| 401 | `Invalid token authorization` | Thiếu/sai Bearer token |
| 404 | `Order not found` | `id` không tồn tại hoặc order đã bị xoá mềm |
| 400 | `Cannot send payment link for a cancelled or completed order` | Order đang `cancel = true` hoặc đã ở status "Komplett" — **FE nên tự ẩn nút trước khi gọi API** (xem field `isCompleted`/`cancel` bên dưới), lỗi này là lớp bảo vệ phía BE |
| 400 | `Missing email` | Thiếu `email` |
| 400 | `Invalid email` | `email` sai định dạng |
| 400 | `Invalid email copy` | `email_copy` có gửi nhưng sai định dạng |
| 400 | `Please select payment type before submit` | Thiếu `payment_type_id` |
| 400 | `Invalid payment type` | `payment_type_id` không khớp `status_list` nào (`type = order_payment_type`) |
| 400/xxx | *(message động từ MailService)* | Lưu token/status vào DB **đã thành công**, nhưng gửi email thất bại — order **đã** chuyển status "Send Payment Link" dù email lỗi. FE nên cho phép staff bấm "Send payment" lại (resend) để gửi lại email |
| 500 | *(exception message)* | Lỗi hệ thống không lường trước |

⚠️ **Quan trọng — thay đổi kiến trúc (2026-08-20)**: endpoint này **không còn gọi Svea (hay bất kỳ payment provider nào)** — chỉ generate token + lưu state + gửi email, nên **sẽ không bao giờ trả lỗi do Svea** nữa (khác với thiết kế ban đầu). Việc tạo phiên thanh toán thật với Svea dời sang lúc khách mở trang `/payment-link/{token}` (route public, không phải API cho FE React). Hệ quả: nếu Svea có vấn đề (vd sai credentials, downtime), **staff vẫn gửi link được bình thường** (nhận `200` OK) — lỗi chỉ xuất hiện khi khách thật sự click vào link, lúc đó khách sẽ thấy trang lỗi "Cannot load checkout, please contact us" từ `payment_link/error.html.twig`, không phải lỗi từ endpoint này. Nghĩa là **response `200` từ endpoint này không đảm bảo Svea checkout chắc chắn hoạt động** — chỉ đảm bảo email đã gửi thành công.

### 2. Field mới trên Order object (áp dụng cho **mọi** endpoint trả order — `GET /orders/{id}`, `GET /orders` list, response của chính endpoint trên)

| Field | Type | Ghi chú cho FE |
|---|---|---|
| `isCompleted` | boolean | order đang ở status "Komplett" hay không (tra live từ `status_list`, không hardcode id) |
| `canSendPaymentLink` | boolean | **Field khuyến nghị dùng để quyết định ẩn/hiện nút "Send payment"** (thêm ở "Self-review round 6") — đã gộp sẵn `!cancel && !isCompleted`, FE dùng trực tiếp field này thay vì tự combine 2 field kia. Cùng 1 rule được BE dùng lại để chặn cả API `send-payment-link` lẫn trang checkout khách mở — nếu FE lỡ không ẩn nút, bấm gửi vẫn sẽ bị BE chặn với message tương ứng, không tạo được state sai |
| `paymentLinkToken` | string \| null | `null` nếu order chưa từng gửi payment link. **Read-only** — xem lưu ý bên dưới |
| `paymentLinkEmail` | string \| null | **Mới thêm (sau khi trao đổi thêm về flow email)** — email mà staff đã gửi link tới (giá trị field `email` trong request `send-payment-link` lần gần nhất). Dùng hiển thị "Đã gửi tới: {email}" trong badge/tooltip cho staff biết chính xác đã gửi cho ai, đặc biệt hữu ích khi email này khác `order.customerEmail` |
| `paymentLinkSentAt` | datetime string \| null | Thời điểm gửi link **gần nhất** (mỗi lần resend sẽ update giá trị này) — dùng hiển thị badge "Sent {date}" theo mockup |
| `paymentLinkExpiresAt` | datetime string \| null | Hạn dùng link = `paymentLinkSentAt + 30 ngày`. Có thể dùng để hiển thị thêm "Expires {date}" nếu cần, mockup không yêu cầu nhưng hữu ích |
| `paymentLinkPaymentTypeId` | integer \| null | Payment type staff đã chọn khi gửi link (chưa chắc đã được gán vào `order.paymentTypeId` — chỉ gán **sau khi** khách thanh toán xong) |

⚠️ **Field KHÔNG trả về FE**: `paymentLinkSveaOrderId` (Svea order id nội bộ) — chỉ dùng backend, không cần thiết cho FE.

⚠️ **Cả 6 field `paymentLink*` đều READ-ONLY từ phía FE**: nếu FE gửi các field này trong body của `PUT /api/v1/orders/{id}` (update order thông thường), BE sẽ **âm thầm bỏ qua**, không lỗi nhưng cũng không có tác dụng gì (đã đưa vào danh sách field bị chặn ghi trong `generateUpdate()`). Các field này chỉ có thể thay đổi qua `POST /orders/{id}/send-payment-link` hoặc do khách thanh toán xong (webhook nội bộ, FE không gọi trực tiếp).

### 3. Route public khác (không phải API cho React — khách hàng click từ email, FE không cần gọi, chỉ liệt kê để biết context)

| Route | Method | Mục đích |
|---|---|---|
| `/payment-link/{token}` | GET | Trang checkout Svea độc lập, khách click từ email vào đây |
| `/payment-link/{token}/terms` | GET | Trang điều khoản (Svea bắt buộc phải có URL này) |
| `/payment-link/{token}/svea-confirm` | GET/POST | Svea redirect khách về đây ngay sau khi submit thanh toán |
| `/payment-link/{token}/svea-push` | POST | Svea gọi server-to-server để báo kết quả thanh toán |
| `/payment-link/{token}/svea-webhook` | GET/POST | Tương tự push, Svea gọi cả 2 để đảm bảo redundancy |

### 4. Lưu ý quan trọng cho FE khi làm popup "Send payment"

1. **Luôn prefill `email_content` bằng nội dung template mặc định** (không để textarea trống) — nếu để trống thì BE tự fallback dùng template, **an toàn**; nhưng nếu staff gõ chữ vào rồi xoá sạch phần `{{ link }}` mà submit, email sẽ thiếu link thanh toán (bẫy có sẵn từ trước ở các popup gửi mail khác trong hệ thống — `orderPrintFoljesedel` cũng có bẫy này, không phải điểm mới). Nếu có thể, hiển thị warning nhỏ trong popup nhắc staff không xoá `{{ link }}`.
2. **`payment_type_id` dùng chung dropdown/data source với các chỗ khác trong hệ thống** đang hiển thị payment type (id lấy từ `status_list` type `order_payment_type` — cùng nguồn data với dropdown "Betalningstyp" hiện có trên order detail).
3. **Badge "Sent"**: chỉ cần check `paymentLinkSentAt !== null`, không cần so sánh với `paymentLinkExpiresAt` để biết "đã gửi" hay chưa (2 khái niệm khác nhau — "đã gửi" vs "còn hạn dùng").
4. **Sau khi resend thành công**, `paymentLinkToken` cũ (nếu khách đang mở link cũ trong tab khác) sẽ tự động thành "Link is invalid" — không cần FE làm gì thêm để vô hiệu hoá link cũ.
5. **Recipient của 2 email trong flow này** — cập nhật lại sau khi bàn thêm (2026-08-20):
   - Email "gửi link thanh toán" (endpoint này) → gửi tới đúng `email`/`email_copy` mà staff nhập trong popup, **không** đụng tới `order.customerEmail` dù popup có prefill sẵn field `email` từ đó. Giá trị `email` này được BE lưu lại vào `paymentLinkEmail` trên order.
   - Email "xác nhận đã thanh toán" (`sendPaymentLinkConfirm`, tự động gửi sau khi Svea báo thanh toán thành công qua webhook — **không phải do FE gọi**) → giờ gửi tới **cả 2 địa chỉ**: `order.customerEmail` (email gốc của khách trên order) **và** `order.paymentLinkEmail` (email lúc gửi link) — nếu 2 địa chỉ này trùng nhau (case-insensitive) thì chỉ gửi **1 email duy nhất** (dedupe), tránh spam 2 email giống hệt vào cùng 1 inbox khi staff không đổi email lúc gửi link (trường hợp phổ biến nhất). FE không cần làm gì thêm cho việc này — hoàn toàn tự động ở BE.

---

## TODO List — Chi tiết

### Backend — Entity & Migration

- [x] **[Order entity]** Thêm 6 fields mới vào `src/Entity/Order.php` (5 field ban đầu + 1 field bổ sung sau khi bàn thêm về flow gửi 2 email):
  - `paymentLinkToken` — `string, nullable` — token bảo mật
  - `paymentLinkEmail` — `string, nullable` — **[bổ sung 2026-08-20]** email staff đã gửi link tới (lưu lại để webhook biết gửi confirm về đâu, xem mục "Backend — Service" bên dưới)
  - `paymentLinkSentAt` — `datetime, nullable` — thời điểm gửi link gần nhất
  - `paymentLinkExpiresAt` — `datetime, nullable` — thời điểm hết hạn (sentAt + 30 ngày)
  - `paymentLinkPaymentTypeId` — `integer, nullable` — payment type sẽ gán sau khi thanh toán
  - `paymentLinkSveaOrderId` — `integer, nullable` — Svea order ID của payment link hiện tại
  - Thêm getter/setter cho cả 6 fields

- [x] **[Migration — schema]** `migrations/Version20260820022905.php` (5 field ban đầu) + `migrations/Version20260820033742.php` (bổ sung `payment_link_email`) — cả 2 lần `doctrine:migrations:diff` đều bắt kèm schema drift không liên quan (activity_log partition tables) → đã lọc migration chỉ giữ đúng phần `ALTER TABLE orders ADD ...` liên quan. Đã chạy `migrate` cả 2, verify bằng `doctrine:migrations:diff` lại lần nữa mỗi lần (không còn diff nào liên quan đến `orders`).

- [x] **[Migration — data]** `migrations/Version20260820023200.php` — 2 status này đã xác nhận **chưa tồn tại** trong `status_list`, đã insert với `position = 1001/1002` (tiếp theo max `position = 1000` hiện có trong `order_status`, không để `0`). Đã chạy migrate và verify bằng SQL — id 854 (`Send Payment Link`) và 855 (`Pay By Link`). Theo đúng pattern migration `Version20260818090000.php` — TSHIRTORDER-1601, sequence `my_status_list_id_seq`, idempotent bằng `WHERE NOT EXISTS`, có `down()` soft-delete lại:
  ```php
  public function up(Schema $schema): void
  {
      $this->addSql(<<<SQL
          INSERT INTO status_list (id, name, type, position, is_active, unique_key, date_created, date_updated)
          SELECT nextval('my_status_list_id_seq'), 'Send Payment Link', 'order_status', 0, true, 'order_status_send_payment_link', NOW(), NOW()
          WHERE NOT EXISTS (
              SELECT 1 FROM status_list
              WHERE type = 'order_status' AND unique_key = 'order_status_send_payment_link' AND date_deleted IS NULL
          )
      SQL);

      $this->addSql(<<<SQL
          INSERT INTO status_list (id, name, type, position, is_active, unique_key, date_created, date_updated)
          SELECT nextval('my_status_list_id_seq'), 'Pay By Link', 'order_status', 0, true, 'order_status_pay_by_link', NOW(), NOW()
          WHERE NOT EXISTS (
              SELECT 1 FROM status_list
              WHERE type = 'order_status' AND unique_key = 'order_status_pay_by_link' AND date_deleted IS NULL
          )
      SQL);
  }

  public function down(Schema $schema): void
  {
      $this->addSql("UPDATE status_list SET date_deleted = NOW() WHERE type = 'order_status' AND unique_key = 'order_status_send_payment_link'");
      $this->addSql("UPDATE status_list SET date_deleted = NOW() WHERE type = 'order_status' AND unique_key = 'order_status_pay_by_link'");
  }
  ```
  - `type = StatusList::TYPE_ORDER_STATUS` ('order_status'), `name = 'Send Payment Link'`, `uniqueKey = 'order_status_send_payment_link'`
  - `type = StatusList::TYPE_ORDER_STATUS` ('order_status'), `name = 'Pay By Link'`, `uniqueKey = 'order_status_pay_by_link'`
  - `position = 1001 / 1002`

- [x] **[Lookup pattern]** Xác nhận: codebase hiện tại **không** định nghĩa constant cho `uniqueKey` trong `StatusList` entity — chỗ khác (`OrderService::listIncompleteOrderProduct`, dòng ~684) query thẳng bằng string literal:
  ```php
  $status = $this->em->getRepository(StatusList::class)->findOneBy([
      'type' => StatusList::TYPE_ORDER_STATUS,
      'uniqueKey' => 'order_status_send_payment_link', // hoặc 'order_status_pay_by_link'
      'dateDeleted' => null
  ]);
  ```
  → theo đúng convention này (dùng string literal trực tiếp, không thêm constant mới), và xử lý case `!$status` trả lỗi tương tự pattern có sẵn.

---

### Backend — Service

- [x] **[SveaService]** Thêm method `createOrderFromOrder(Order $order, string $token): array` vào `src/Service/Payment/SveaService.php` — **bỏ tham số `Channel $channel`** so với plan ban đầu: đúng theo quyết định kiến trúc ở trên (Svea creds global từ `.env`, không phụ thuộc channel):
  - Tạo Svea checkout order từ `Order->getItems()` (không dùng `EcomOrderTemp`)
  - `clientOrderNumber` = `{order.orderNr}-{8 ký tự đầu của token}` (không dùng thẳng orderNr — xem "Self-review" bên dưới, bug #3)
  - Items loop: `getProductSku()`, `getProductName()`, `getQuantity()`, `getPrice()`, `getProductSizeText()`
  - Shipping fee từ `$order->getShippingFee()` — cố định, **không** hiển thị UI cho khách chọn lại phương thức vận chuyển
  - `presetValues`: chỉ 3 field có trong danh sách hỗ trợ chính thức của Svea (README §11.4) — `emailAddress`, `phoneNumber`, `postalCode`. **Đã bỏ `street`/`city`** sau khi đọc kỹ README (xem "Self-review" — bug #2): Svea Checkout API **không hỗ trợ prefill** tên/địa chỉ/thành phố qua `presetValues`, đây là giới hạn thật của Svea chứ không phải thiếu sót — nghĩa là mockup "Prefill with customer information" **chỉ prefill được email/sđt/mã bưu điện**, không prefill được Namn/Adress/Stad như hình mockup thể hiện.
  - `confirmationUri`/`pushUri`/`webhookUri` → route `payment_link_svea_confirm`/`payment_link_svea_push`/`payment_link_svea_webhook` với `{token}`
  - `termsUri`/`checkoutUri` → **bắt buộc theo Svea** (README §11.1), route mới `payment_link_terms` (trang điều khoản tối giản) và tự trỏ về `payment_link_checkout` — xem "Self-review" bug #1 (bản đầu thiếu 2 field này, chắc chắn bị Svea reject)
  - Thêm `SveaService::cancelOrder($orderId)` (dùng `CheckoutAdminClient::cancelOrder()`) — gọi best-effort trong `OrderService::sendPaymentLink()` để dọn Svea order cũ khi resend (xem bug #4)

- [x] **[MailService]** Thêm method `sendPaymentLink(Order $order, string $link, string $to, $toCopy, string $emailContent, string $userLoginEmail = '', $loginUser = null): array` vào `src/Service/MailService.php`:
  - Dùng email template key `payment_link_send`
  - **Không tạo migration riêng cho email_template** (xem mục "Backend — Email Templates" bên dưới — đã đổi hướng)
  - Theo đúng convention có sẵn ở `orderPrintFoljesedel()`/`invoiceReminder()`: nếu staff không nhập `emailContent`, fallback dùng `$emailTemplate->getContent()`; subject luôn render từ `$emailTemplate->getSubject()`. Body render bằng Twig thật (`renderMailBody()` dùng `$twig->createTemplate()`), placeholder cú pháp `{{ name }}`, `{{ orderNr }}`, `{{ link }}`, `{{ expiresAt }}`
  - ⚠️ **Lưu ý bẫy quan trọng** (kế thừa từ pattern có sẵn, không phải bug mới): nếu staff tự gõ `emailContent` custom mà **không** gõ `{{ link }}` trong đó, email gửi đi sẽ **không có link thanh toán** — vì body = `emailContent` khi non-empty, không phải nối thêm vào template. FE popup nên prefill sẵn textarea bằng nội dung template mặc định (đã có `{{ link }}`) để staff sửa trên nền đó, không để trắng.
  - Gửi CC nếu `$toCopy` không rỗng (dùng `bcc`, đúng theo convention `orderPrintFoljesedel`/`orderKorrektur`)

- [x] **[MailService]** Thêm method `sendPaymentLinkConfirm(Order $order, string $to, string $userLoginEmail = '', $loginUser = null): array`:
  - Dùng email template key `payment_link_confirm`
  - Body render với params: `name`, `orderNr`, `totalSum` (lấy từ `$order->getTotalSumInclTax()`)

- [x] **[OrderService]** Thêm method `sendPaymentLink(int $id, $data, $loginUser = null): array` vào `src/Service/OrderService.php` — đặt ngay sau `sendMail()` (dòng ~4663), theo đúng style validate/return của method đó:
  - Validate: `email` bắt buộc + hợp lệ (dùng lại `Symfony\...\Constraints\Email`), `email_copy` optional + hợp lệ nếu có, `payment_type_id` **bắt buộc** (theo quyết định Q3 = Option A)
  - Tìm order theo `$id`, check `dateDeleted = null` → 404 nếu không thấy
  - Check qua `canSendPaymentLink($order)` (method dùng chung, xem "Self-review round 6") → 400 nếu order đang cancel hoặc đã ở status "Komplett"
  - Generate token: `bin2hex(random_bytes(32))`, `expiresAt = now + 30 days` (dùng `\DateTime::modify('+30 days')`)
  - **[Đổi hướng — Self-review round 3 + 5]** **Không gọi Svea (hay bất kỳ payment provider nào) ở method này** — xem lý do đầy đủ ở mục "Self-review round 3" phía trên. Việc tạo phiên thanh toán mới hoàn toàn dời sang `PaymentLinkController::checkout()`/`sveaGenerateOrder()`, chỉ chạy khi khách thật sự mở link và chọn method. *(Round 3 có tạm giữ 1 bước `sveaService->cancelOrder()` best-effort để dọn Svea order cũ lúc resend — round 5 đã bỏ hẳn theo yêu cầu, method này giờ 100% không còn dòng nào import/gọi `SveaService`. Svea order cũ nếu có sẽ không bị chủ động cancel nữa, để tự expire.)*
  - Set `paymentLinkToken/Email/SentAt/ExpiresAt/PaymentTypeId` — `paymentLinkSveaOrderId` luôn set về `null` ở bước này (chưa tạo) — `paymentLinkEmail = $email` (lưu lại email đã gửi để webhook dùng khi gửi confirm, xem "Self-review round 2")
  - Set status qua `findOneBy(['type' => StatusList::TYPE_ORDER_STATUS, 'uniqueKey' => 'order_status_send_payment_link', 'dateDeleted' => null])`, đồng thời set `orderStatusIdPrevious`/`orderStatusPrevious` trước khi ghi đè (field này có sẵn trên Order, đang được expose ở `generateItem()` nhưng trước giờ chưa có chỗ nào trong OrderService set nó — set cho đúng ý nghĩa field)
  - Prepend comment: `'<p>[' . $now->format('Y-m-d H:i:s') . '] Payment link sent to ' . $email . '</p>' . $order->getComment()` — dùng thẻ `<p>` (comment field lưu HTML, theo đúng convention thấy ở `OrderService.php:2557` nơi ghi `payment_id`/`date_paid`)
  - `$em->flush()`
  - Build link tuyệt đối bằng `$router->generate('payment_link_checkout', ['token' => $token], UrlGeneratorInterface::ABSOLUTE_URL)`
  - Gọi `mailService->sendPaymentLink(...)` — nếu lỗi, trả luôn status_code/message của MailService (order vẫn đã lưu token/status — resend sẽ sửa được nếu email fail)
  - Trả `['status_code' => 200, 'data' => generateItem($order)]`

- [x] **[OrderService]** Cập nhật `generateItemForList()` (không phải `generateItem()` — đây là nơi field cấp order thật sự được build, `generateItem()` gọi `generateItemForList()` bên trong) — thêm vào array trả về:
  - `paymentLinkToken`, `paymentLinkEmail` (**bổ sung 2026-08-20**), `paymentLinkSentAt`, `paymentLinkExpiresAt`, `paymentLinkPaymentTypeId`
  - **Thêm mới so với plan**: `isCompleted` (bool, so `orderStatusId` với id status "Komplett" — tra live từ `status_list`, xem "Self-review round 6") và `canSendPaymentLink` (bool, dùng chung logic với `sendPaymentLink()`/checkout page) — để FE ẩn nút "Send payment" mà không phải tự combine `cancel` + `isCompleted` hay hardcode id 22 (xem mục Frontend bên dưới)
  - **Thêm mới so với plan**: đã thêm cả 6 field `paymentLink*` vào danh sách `$notUpdatedFields` trong `generateUpdate()` — tránh bị FE vô tình ghi đè qua API update order chung (`PUT /orders/{id}`), vì các field này chỉ nên được set qua flow `sendPaymentLink`/webhook, không qua form edit order thường

---

### Backend — Controller & Routing

- [x] **[OrderController]** Thêm action `sendPaymentLink($id, Request $request, ApiService $apiService, OrderService $orderService)` vào `src/Application/ApiBundle/Controller/OrderController.php` — copy chính xác pattern của action `sendMail()` ngay phía trên nó (cùng `$apiService->getToken($authorization, true, false, true)`).

- [x] **[API Route]** Thêm vào `src/Application/ApiBundle/Resources/config/route/order.yaml`, verify bằng `debug:router` — OK: `POST /api/v1/orders/{id}/send-payment-link`.

- [x] **[PaymentLinkController]** Tạo mới `src/Application/WebshopBundle/Controller/PaymentLinkController.php`. **Khác plan**: gộp `sveaPush`/`sveaWebhook` vào chung 1 private method `handleSveaCallback()` (2 action gọi chung logic, đúng như comment "Svea gọi cả webhook lẫn push" trong plan gốc — không có lý do tách code riêng cho 2 route giống hệt nhau, đúng pattern DRY):
  - `checkout($token, ...)`: tìm order theo `paymentLinkToken` → nếu không thấy trả trang lỗi "Link is invalid"; nếu order đã ở status "Pay By Link" (query qua `uniqueKey`) → render `confirm.html.twig` luôn (khách bấm lại link cũ sau khi đã thanh toán không thấy lỗi mà thấy trang "đã thanh toán" — thân thiện hơn "link expired"); nếu `paymentLinkExpiresAt` null hoặc `< now` → trang lỗi hết hạn; **[Self-review round 3]** nếu `paymentLinkSveaOrderId` đã có (khách mở lại link) → `sveaService->get()` lấy session cũ, nếu **chưa có** (lần đầu mở) → `sveaService->createOrderFromOrder($order, $token)` tạo mới rồi lưu `OrderId` + `flush()` — đây là điểm duy nhất trong toàn bộ flow thật sự gọi Svea để tạo order (không còn ở `sendPaymentLink()` nữa); lỗi hoặc thiếu `Gui.Snippet` (cả 2 nhánh) → trang lỗi; `Status == 'Cancelled'` → trang lỗi riêng
  - `sveaConfirm($token, ...)`: tìm order theo token, không tồn tại → trang lỗi; luôn render `confirm.html.twig` (đây là trang khách thấy ngay sau khi submit Svea checkout, **trước khi** push/webhook xử lý xong — không nên tự ý set status ở đây, chỉ ở push/webhook)
  - `handleSveaCallback()` (dùng chung cho push + webhook): không tìm thấy order → `404`; idempotency guard bằng sentinel trong comment (không dùng `orderStatusId`, xem "Self-review" bug #5) — nếu **đã** xử lý rồi thì return `ok 200` ngay; `Status == 'Final'` → set status/paymentStatusId=738/paymentTypeId + prepend comment `payment_id`/`date_paid` (đúng format mockup) + flush + gửi `sendPaymentLinkConfirm` **tới cả `order.customerEmail` và `order.paymentLinkEmail`, dedupe nếu trùng** (**bổ sung 2026-08-20** — trước đó chỉ gửi 1 email tới `customerEmail`, xem "Self-review round 2" bên dưới); `Status == 'Cancelled'` → không làm gì (giữ nguyên status "Send Payment Link", staff có thể resend); luôn `try/catch` trả `500` kèm message khi có exception

- [x] **[Payment Link Routing]** Tạo mới `src/Application/WebshopBundle/Resources/config/payment_link_routing.yaml` — đặt route `/{token}` (catch-all 1 segment) **sau cùng**, sau 3 route `/{token}/svea-*` cụ thể hơn (thứ tự khai báo route ảnh hưởng matching trong Symfony, dù về mặt kỹ thuật `{token}` mặc định không match dấu `/` nên không xung đột — vẫn đặt đúng thứ tự cho rõ ràng).

- [x] **[config/routes.yaml]** Thêm entry `payment_link` với prefix `/payment-link`, verify bằng `debug:router` — OK: cả 5 route (gồm `payment_link_terms` thêm sau self-review) đều lên đúng path.

---

### Backend — Email Templates

- [x] **Đổi hướng so với plan ban đầu**: phát hiện `src/Application/ApiBundle/Controller/EmailTemplateController.php` + `EmailTemplateService.php` đã tồn tại sẵn (CRUD email template qua admin UI/API) — nghĩa là email template trong hệ thống này thường được **quản lý qua admin UI**, không như `status_list` (chỉ tạo qua migration).
  - 2 method `MailService::sendPaymentLink()`/`sendPaymentLinkConfirm()` vẫn **tự tạo template mặc định inline nếu chưa tồn tại** (đúng pattern có sẵn ở `MailService::invoiceReminder()`/`createNewProductFromGuest()`) — cơ chế self-heal, phòng trường hợp migration chưa kịp chạy ở môi trường nào đó.
  - **[Bổ sung sau — theo yêu cầu]**: thêm migration `migrations/Version20260820061500.php` insert sẵn 2 template này vào DB (idempotent qua `WHERE NOT EXISTS`, `down()` là `DELETE` cứng vì bảng `email_template` không có `date_deleted`) — lý do: nếu để tự tạo lazy lúc gửi lần đầu thì **staff không có cơ hội sửa nội dung trước khi email đầu tiên đã gửi ra ngoài** với nội dung mặc định (tiếng Thuỵ Điển do tôi tự viết, chưa qua duyệt). Có migration này, sau khi deploy staff vào ngay `EmailTemplateController` sửa nội dung/subject **trước khi** có bất kỳ email nào thật sự gửi đi.
  - Đã verify trên DB dev: `payment_link_send` (id=100, đã tồn tại từ lúc tự tạo lazy trong lúc test trước đó) — migration chạy qua, đúng như kỳ vọng **không tạo trùng**; `payment_link_confirm` (id=101) — mới, được insert đúng.

---

### Frontend — React *(repo khác, chưa làm — verify: không có `package.json`/thư mục React trong repo Symfony này)*

- [ ] **[Button]** Thêm button **"Send payment"** vào order detail page — vị trí theo mockup (`files/mockup-order-detail-send-button.png`): **cạnh nút "Redigera"**, cùng hàng với tiêu đề `Order: {orderNr}` (không đặt ở thanh action trên cùng cùng Avbryt order/Skicka via e-post/...):
  - Ẩn nếu `order.cancel === true`
  - Ẩn nếu `order.canSendPaymentLink === false` (field gộp sẵn `!cancel && !isCompleted` — xem "Self-review round 6", không cần tự combine 2 field hay hardcode id 22)
  - Hiển thị badge nhỏ "Sent" + ngày gửi nếu `order.paymentLinkSentAt` không null (để staff biết đã gửi rồi)

- [ ] **[Popup/Dropdown]** Tạo popup "Send payment" (theo pattern của `orderPrintFoljesedel`) — theo mockup là dropdown xác nhận email trước khi gửi, không phải form nhập từ đầu:
  - Field: `email` (required) — **prefill sẵn từ `order.customerEmail`**, staff verify/sửa lại trước khi gửi (annotation "Dropdown to verify email before send")
  - Field: `emailCopy` (optional)
  - Field: `emailContent` (textarea, optional — custom message)
  - Field: `paymentTypeId` (dropdown chọn payment type, required — theo quyết định Option A ở mục "Q3 — Resolved")
  - Submit → `POST /api/v1/orders/{id}/send-payment-link`
  - Hiển thị loading, success toast, đóng popup sau khi gửi thành công

---

### Templates — Twig

- [x] **[payment_link/checkout.html.twig]** Extends thẳng `base.html.twig` (không qua `ecom.html.twig` — template đó cần `channel`/menu context mà route payment-link không có), tự include CSS `bootstrap.min.css` + `common.css` + `standard.css` giống ecom cho đồng bộ UI. Cấu trúc cuối cùng sau round 5 (từ trên xuống):
  - **Header**: logo `public/logo-tshirt.png` (bên trái) + tiêu đề "Kassa" kèm `order.orderNr`/`order.dateOrder` (`Y-m-d`)/`order.totalSumInclTax` (bên phải) — thay cho `<h1>Kassa</h1>` đơn giản ban đầu
  - **"Dina Produkter"**: đọc trực tiếp từ `order.items` (Doctrine Collection, không qua `generateItem()`) — tên sản phẩm + size + sku + số lượng + đơn giá x số lượng. **Bỏ cột "Kategori"** so với mockup — `OrderProduct` entity không lưu category theo item, category trong mockup chỉ có ở ecom cart data, không map được 1-1 sang order đã chốt. Kèm **"Rabattkod"** read-only (chỉ hiện nếu `order.couponCode` có giá trị, không có input) + "Frakt: X kr" nếu `order.shippingFee` khác 0 — không có UI chọn Leverans/coupon mới
  - **"Betalning"**: radio `svea` (kèm logo `webshop/images/svea.jpg`), **không `checked` mặc định** — chỉ khi user tự chọn mới trigger AJAX
  - **"Kunduppgifter"**: luôn hiện, đọc thẳng từ `order.customerName/Address/PostCode/City/Country/Email/Mobile` — độc lập hoàn toàn với Svea/payment method (không qua `presetValues` vì Svea không hỗ trợ prefill các field này)
  - **"Svea Kassa"** (heading tiếng Thuỵ Điển, tự đặt vì repo chưa có bản dịch) + `#svea-checkout-iframe` — `display: none` mặc định (`class="show-svea"`), chỉ `.show()`/gọi AJAX `payment_link_svea_generate_order` khi radio Svea được chọn (`change` event), mirror JS `loadSvea()` của `checkout.html.twig` gốc
  - jQuery (`webshop/js/jquery-3.5.1.min.js`) tự include trong `{% block javascripts %}` vì không qua `ecom.html.twig`
  - **Không làm**: "Eventuellt meddelande om din order" — bỏ qua vì field tương ứng (`order.comment`) đang dùng chung cho log nội bộ, hiển thị cho khách sẽ lộ thông tin không nên thấy

- [x] **[payment_link/confirm.html.twig]** Trang cảm ơn — "Tack! Din betalning för order {{ order.orderNr }} är mottagen."

- [x] **[payment_link/error.html.twig]** Nhận param `message`, hiển thị qua `{{ message | trans }}`

- [x] Lint qua `php bin/console lint:twig templates/payment_link/` — OK, cả 3 file hợp lệ.

---

## Estimate cập nhật

| # | Task | Giờ |
|---|------|-----|
| 1 | Order entity + 2 migrations (schema + data) | 1h |
| 2 | SveaService::createOrderFromOrder() | 1.5h |
| 3 | MailService: 2 methods mới | 1h |
| 4 | OrderService::sendPaymentLink() + generateItem() update | 2h |
| 5 | OrderController + API route | 0.5h |
| 6 | PaymentLinkController (checkout + push + confirm + webhook) | 3h |
| 7 | Routing files + config/routes.yaml | 0.5h |
| 8 | 3 Twig templates (checkout, confirm, error) | 1.5h |
| 9 | Frontend: button + popup (React) | 3h |

**Tổng: ~14h ≈ 2 ngày làm việc**

---

## Các file liên quan

| File | Mục đích |
|------|----------|
| `src/Entity/Order.php` | Thêm 6 fields payment link (gồm `paymentLinkEmail` bổ sung round 2) + getter/setter |
| `src/Service/Payment/SveaService.php` | Thêm `createOrderFromOrder(Order $order, string $token)`, `cancelOrder($orderId)`; sửa constructor dùng `svea_use_test_environment` thay vì hardcode PROD/TEST (round 7 — ảnh hưởng chung cả ecom checkout) |
| `.env` | Thêm `SVEA_USE_TEST=0` (mặc định an toàn, commit chung) — round 7 |
| `.env.local` | **Không commit** — tạo mới trên máy dev, `SVEA_USE_TEST=1` — round 7 |
| `config/services.yaml` | Thêm param `svea_use_test_environment` — round 7 |
| `src/Service/MailService.php` | Thêm `sendPaymentLink()`, `sendPaymentLinkConfirm()` (tự tạo template nếu chưa có) |
| `src/Service/OrderService.php` | Thêm `sendPaymentLink()` (có set `paymentLinkEmail`), `canSendPaymentLink()` (public, dùng chung), `getCompleteStatusId()` (private, cache instance, query live `status_list`); update `generateItemForList()` (+`isCompleted`, +`canSendPaymentLink`, +6 field paymentLink*); update `generateUpdate()` (+6 field vào `$notUpdatedFields`) |
| `src/Application/ApiBundle/Controller/OrderController.php` | Thêm action `sendPaymentLink` |
| `src/Application/ApiBundle/Resources/config/route/order.yaml` | Thêm route `api_order_send_payment_link` — POST `/{id}/send-payment-link` |
| `src/Application/WebshopBundle/Controller/PaymentLinkController.php` | Controller mới — `checkout` (không đụng Svea), `sveaGenerateOrder` (action AJAX tạo Svea order, round 4), `sveaConfirm`/`sveaPush`/`sveaWebhook` (2 action Svea cuối gọi chung `handleSveaCallback()`, gửi confirm tới 2 địa chỉ có dedupe — round 2), `guardPaymentLinkOrder()` (inject thêm `OrderService`, check lại `canSendPaymentLink()` — round 6) |
| `src/Application/WebshopBundle/Resources/config/payment_link_routing.yaml` | Routing file mới — 6 route (thêm `payment_link_terms` ở round 1, `payment_link_svea_generate_order` ở round 4) |
| `config/routes.yaml` | Thêm entry `payment_link`, prefix `/payment-link` |
| `templates/payment_link/checkout.html.twig` | Trang checkout, extends `base.html.twig` — header (logo + order info), Dina Produkter, Rabattkod (read-only), Betalning, Kunduppgifter (read-only), Svea Kassa (ẩn mặc định) |
| `public/logo-tshirt.png` | Logo dùng ở header trang checkout (đã có sẵn trong repo) |
| `public/webshop/images/svea.jpg` | Logo Svea cạnh radio "Svea payment" (đã có sẵn trong repo) |
| `templates/payment_link/confirm.html.twig` | Trang cảm ơn |
| `templates/payment_link/error.html.twig` | Trang lỗi |
| `templates/payment_link/terms.html.twig` | Trang điều khoản tối giản — thêm sau self-review vì Svea bắt buộc `termsUri` |
| `migrations/Version20260820022905.php` | ALTER TABLE orders — 5 field payment link (đã lọc bỏ schema drift không liên quan do `migrations:diff` bắt nhầm) |
| `migrations/Version20260820023200.php` | INSERT 2 status vào `status_list` (`order_status_send_payment_link` id=854, `order_status_pay_by_link` id=855) |
| `migrations/Version20260820033742.php` | ALTER TABLE orders — thêm `payment_link_email` (self-review round 2) |
| `migrations/Version20260820061500.php` | Seed `email_template` — `payment_link_send` (id=100), `payment_link_confirm` (id=101), để staff sửa nội dung trước khi gửi lần đầu |
| `files/mockup-order-detail-send-button.png` | Mockup vị trí button "Send payment" + dropdown verify email + comment format sau khi thanh toán |
| `files/mockup-payment-link-checkout.png` | Mockup trang checkout: ẩn Leverans, prefill Kunduppgifter |