13 KiB
Order → Invoice → Payment/Recurring Billing — Fix TODO
Nguồn: audit ngày 2026-09-05 (trace thật + chạy test/verify code, không chỉ đọc). Thứ tự đề xuất: sửa từ trên xuống, mỗi mục độc lập, có thể merge riêng lẻ.
P0 — Sai cấu trúc nghiệp vụ cốt lõi ✅ DONE (2026-09-05)
-
Renewal tạo Order mới thay vì tái dùng Order gốc File:
app/Console/Commands/ServiceExpiry.php(createRenewalInvoiceIfMissing()),app/Services/Api/Order/OrderQueryService.php::renewService()Đã sửa: bỏ hẳnOrder::create()/OrderItem::create()khi renewal;Invoice::create(['order_id' => $orderItem->order_id, ...])trỏ về Order gốc. Test:tests/Feature/Api/Service/RenewServiceTest.php::test_renew_reuses_original_order_instead_of_creating_a_new_one,tests/Feature/Console/ServiceExpiryTest.php::test_expiry_command_creates_renewal_invoice_on_the_original_order— PASS. -
Thiếu khái niệm billing_period trên Invoice File:
database/migrations/2026_09_05_000006_add_billing_period_to_invoices_table.phpĐã thêmbilling_period_start/billing_period_end(date, nullable), set ở cả 2 nơi tạo renewal invoice. -
Không có unique constraint chống trùng invoice cùng kỳ billing File: cùng migration trên —
unique(order_id, invoice_type, billing_period_start)(invoices_order_type_period_unique). Test:test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period,test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice— PASS (gọi renew 2 lần / chạy cron 2 lần chỉ ra đúng 1 invoice). Ghi chú: guardexists()-trước-khi-tạo ở tầng app vẫn còn (chưa bắt QueryException dự phòng cho race thật sự đồng thời) — để dành nếu sau này thấy còn lọt duplicate qua race test tải cao.
P1 — Race condition tài chính ✅ DONE (2026-09-05)
-
Double-spend balance khi thanh toán đồng thời File:
app/Services/InvoicePaymentService.php::payWithBalance()Đã sửa tại nguồn (không sửa từng caller riêng): lockInvoice+CustomerbằnglockForUpdate()bên trongDB::transaction(), re-check status/balance sau khi lock, trước khi trừ tiền. Áp dụng cho cả 2 caller (OrderService::pay()vàOrderControllerRefactor::payClientInvoiceByNumber()). Test:tests/Feature/Payment/InvoicePaymentServiceTest.php::test_paying_an_already_paid_invoice_does_not_double_deduct_balance— gọipayWithBalance()2 lần liên tiếp cho cùng invoice, xác nhận chỉ trừ tiền 1 lần — PASS. -
Duyệt trùng nạp tiền thủ công (admin) File:
app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php::approve()vàreject()Đã sửa: gộp toàn bộ read-check-write vào 1DB::transaction(), lock cảTransactionvàCustomerrow bằnglockForUpdate(), tách phần gửi mail ra ngoài transaction (tránh giữ lock khi I/O). Test:tests/Feature/Payment/AdminTransactionApproveTest.php::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once— gọiapprove()2 lần cho cùng uuid, xác nhận balance chỉ cộng 1 lần, lần 2 trả 404 — PASS. -
Dedup transaction chỉ ở tầng app, không có unique DB File:
database/migrations/2026_09_05_000007_add_unique_constraint_to_transactions_table.php—unique(customer_id, trans, type). Verify trước khi thêm: query production DB xác nhận 0 nhóm trùng(customer_id, trans, type)hiện có, migration chạy sạch. Ghi chú: không cần sửa thêm code —Model::firstOrCreate()ở Laravel 10 tự bắtUniqueConstraintViolationExceptionvà fallback về SELECT (xemvendor/laravel/framework/.../Eloquent/Builder.php::createOrFirst()), nênInvoicePaymentService::recordTransaction()(đã dùngfirstOrCreate) tự động trở nên race-safe thật sự sau khi có unique index. Đã hardening thêmapp/Jobs/Payment/GetTransactionPayment.php(bank-statement poller): đổi từ patternwhere()->first()rồicreate()(race window) sangfirstOrCreate()+ checkwasRecentlyCreatedtrước khi cộng tiền, tận dụng cùng cơ chế race-safe trên.
P2 — Nhất quán dữ liệu / logic ✅ DONE (2026-09-05)
-
Giá renewal không nhất quán giữa cron tự động và tự-gia-hạn (CHƯA SỬA — để dành, xem ghi chú) File:
app/Console/Commands/ServiceExpiry.php(dùng giá đông cứng$orderItem->pricelúc mua ban đầu) vsapp/Services/Api/Order/OrderQueryService.php::renewService()(dùng giá catalog hiện tại quaproductRepo->getPricing()) Ghi chú: đây là quyết định nghiệp vụ (cron tự động có nên áp giá mới như khách tự gia hạn hay giữ giá cũ để tránh sốc giá tự động) — cần hỏi lại business trước khi đổi, chưa tự quyết. -
cycleToMonths()bị duplicate ở 4 nơi Đã gom vềApp\Helpers\OrderHelper::cycleToMonths()(static), thay thế tại:InvoiceRepository,InvoicePaymentService,Api\Order\OrderService,Customer\CustomerServiceProvisioningService. Xóa 4 bảnprivate function cycleToMonths()trùng lặp. Verify: chạy lại toàn bộ test đã có ở P0/P1 sau refactor — tất cả PASS (không đổi hành vi). -
Tax handling không nhất quán giữa order client-facing và admin Quyết định (theo xác nhận của chủ dự án): giá niêm yết đã bao gồm thuế — không cộng thêm % lên trên ở bất kỳ đâu. File:
app/Services/OrderService.php::createOrder()— bỏ$orderTotal = $total + $total*10%, đổi thành$orderTotal = $total;invoice.taxset về0(khớp vớiInvoiceRepository::createFromOrder()phía client-facing vốn đã settax=0). Ghi chú: không viết test riêng cho thay đổi 1 dòng arithmetic này —createOrder()phụ thuộc sâu vào hệ thống module VM/hosting (dựng test tốn công không tương xứng với rủi ro của 1 phép tính đơn giản).
P3 — Frontend ✅ DONE (2026-09-05)
- Bảng Invoice chính thiếu trạng thái "Quá hạn"/"Đã hoàn tiền"
File:
portal-admin/src/features/order-invoice/data/data.tsx— thêm4: Quá hạn,5: Đã hoàn tiềnvàoinvoiceStatuses. Cập nhật màu Tag tương ứng ở 2 nơi tiêu thụ:invoices-columns.tsx(statusColor),invoices-table.tsx(colors). Verify:yarn build— không phát sinh lỗi TS mới trong 3 file đã sửa (các lỗi build hiện có trên nhánh là pre-existing, không liên quan:firstname/lastnametype mismatch ởapp-sidebar.tsx/profile-dropdown.tsx/activity-logs-table.tsx/users-columns.tsx,fmtVNDunused ởcustomers-table.tsx). Không làm: hợp nhất hoàn toàn với map riêng ởcustomer-invoices-tab.tsx— 2 map dùng shape và icon-library khác nhau (@iconify/reactstring icon vslucide-reactcomponent); hợp nhất đòi hỏi đổi shape ở nhiều nơi, rủi ro cao hơn lợi ích so với việc chỉ vá đúng chỗ thiếu status. Để dành nếu sau này cần refactor UI invoice status rộng hơn.
P4 — Dọn dead code (rủi ro nếu vô tình gọi lại) ✅ DONE (2026-09-05)
- Xóa
app/Http/Services/Invoice/InvoiceService.php— dùng cột không tồn tại (order_num,date_paid). - Xóa
app/Jobs/VirtualMachine/ProcessOrderPaymentJob.php— dùng cột không tồn tại (orders.amount,order_items.product_type), không route nào gọi. - Xóa
app/Http/Controllers/Api/Service/PaymentController.php— không được route tới (route sống làOrderControllerRefactor::payOrder). - Xóa
tests/Unit/ProcessOrderPaymentJobTest.php— đã verify chạy FAIL 4/4 (Class "Database\Factories\OrderFactory" not found), test cho code chết. - Xóa
app/Console/Commands/UpdateExpiryDate.php— xác nhậnorder_items.expiry_datekhông tồn tại trong bất kỳ migration nào (grep toàn bộdatabase/migrations), lệnh sẽ lỗi SQL nếu chạy, không có trongKernel::schedule(). - Xóa 2 import chết còn sót trong
app/Http/Controllers/Api/Service/ServiceController.php(use InvoiceService,use ProcessOrderPaymentJob) — import nhưng không dùng ở đâu trong file. Verify:greptoàn repo xác nhận không còn tham chiếu nào tới 5 file/class đã xóa;php -lpass; chạy lại toàn bộ 9 test đã viết ở P0/P1 — vẫn PASS.
P5 — Test cần bổ sung ✅ DONE (2026-09-05) — tất cả 11 test PASS
test_renewal_invoice_uses_original_order_id_not_new_order→RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_onetest_second_renewal_for_same_service_still_uses_original_order_id→ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_ordertest_concurrent_renewal_requests_create_only_one_invoice_for_same_period→RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period,ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoicetest_concurrent_pay_requests_do_not_double_deduct_balance→InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balancetest_duplicate_manual_deposit_approval_does_not_double_credit→AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_oncetest_cancelled_order_does_not_trigger_renewal_invoice_generation→ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoicetest_cancelled_invoice_is_not_counted_as_paid→InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid
Tổng: RenewServiceTest (5), ServiceExpiryTest (4), InvoicePaymentServiceTest (2), AdminTransactionApproveTest (1), InvoiceTestRouteRemovedTest (1, có sẵn) — 13 test, chạy riêng lẻ đều PASS.
P6 — Bug mới phát hiện khi viết test cho case "invoice OVERDUE rồi mới gia hạn tiếp" ✅ DONE (2026-09-05)
invoices:check-overduecrash khi reset order status — do eager-load thiếu cộtcustomer_idFile:app/Console/Commands/CheckOverdueInvoices.phpdòng 39; root cause thật ởapp/Observers/OrderObserver.php::updated()(đăng ký tạiapp/Providers/ObServerProvider.php). Logic sai:Invoice::query()->with('order:id,status')chỉ eager-load 2 cột của Order. Khi cron cần reset order status (ACTIVE → PENDING vì invoice quá hạn),$order->update(['status' => ...])bắn eventupdated,OrderObserver::updated()đọc$order->customer_idđể ghi log — cột này KHÔNG được load nên lànull→ insert vào bảnglog(customer_idNOT NULL) némQueryException, không có try/catch bọc quanh vòng lặp → toàn bộ croninvoices:check-overduedừng ngay tại invoice đầu tiên cần reset order status, các invoice quá hạn còn lại trong ngày không được xử lý. Phát hiện bằng: viết test tích hợp thật (ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue,RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice) — gọiinvoices:check-overduethật (không mock) để mô phỏng đúng flow "invoice tới due_date → OVERDUE" trước khi test hành vi tránh trùng lặp; cả 2 test đều crash vớiSQLSTATE[23000]: Column 'customer_id' cannot be nulltrước khi fix. Cách sửa: đổi->with('order:id,status')thành->with('order:id,status,customer_id')— 1 dòng, không đổi hành vi nghiệp vụ nào khác. Verify: cả 13 test (bao gồm 2 test mới ở trên) PASS sau fix. Business impact trước khi sửa: bất kỳ ngày nào có invoice renewal quá hạn của 1 service đang ACTIVE, croninvoices:check-overduesẽ crash ngay từ invoice đầu tiên loại này và bỏ sót toàn bộ các invoice quá hạn phía sau trong cùng lần chạy — nghĩa là tính năng đánh dấu OVERDUE gần như không hoạt động đúng trong thực tế với dữ liệu thật (production DB có invoice + order theo đúng pattern này). Ghi chú môi trường: test suite tổng (--testsuite Feature) trên nhánhdev-domaincó ~40 lỗi pre-existing không liên quan (401/403/404 do cấu hình auth/route test), đã xác nhận bằnggit stashso sánh baseline trước khi có bất kỳ fix nào ở đây — không phải do các thay đổi trong tài liệu này.
Ghi chú (không cần sửa, giữ nguyên)
CheckOverdueInvoices(croninvoices:check-overdue) đúng nghiệp vụ: chỉ update status, không tạo invoice mới. Không đổi.InvoicePaymentService::activateOrder()dùngfirstOrCreate(order_item_id)chống double-provisioning — đã ổn.- Không cần thêm bảng
subscriptions/billing_schedulesriêng —services+ cronservice:expiryđủ đóng vai trò billing schedule sau khi fix P0.