Appearance
v1.11 — 15/05/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
Đảo DEC-031 sang Option A — chia P2-MOBILE-04 thành 4 subtask (P2-MOBILE-04A/B/C/D): extend wallet_screen.dart thêm tab thứ 3 (dev tự chốt widget); tạo file mới promotion_wallet_2_page.dart; extend WalletBloc thêm 3 event/state cho KM2; extend _buildCard switch 3 tab | C11) Tasks | Mobile, PM |
Thêm P2-01B/C (web admin) — tạo popup StatisticWalletPromotion2Popup.tsx + integrate vào CustomerEWalletInformation.tsx | C11) Tasks | FE Web, PM |
| Update step 11 "Thứ tự triển khai" — Flutter customer thêm tab thứ 3 (không phải screen riêng) | C9.x | Mobile, Ops |
v1.10 — 15/05/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
Thêm 9 task mobile vào C11 Phase 2 (P2-MOBILE-01..09) — DEC-031 Option C (screen mới wallet_km2_screen.dart route /wallet/promotion2) + DEC-032 Option α (không thêm enum vào AffiliateFor) [ĐÃ ĐẢO ở v1.11] | C11) Tasks | Mobile, PM |
| Thêm step 12 vào "Thứ tự triển khai" — FE Deploy Flutter staff cho SCR-10 customer detail balance KM 2 | C9.x | Mobile, Ops |
Lưu ý 3 — release core package shared phải đồng bộ giữa Flutter customer + staff app (WalletType enum + l10n) | C9.x | Mobile, Ops |
v1.8 — 15/05/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
DEC-028: Schema wallet_km2_payment_attempt merge — PK id riêng + UNIQUE payment_attempt_id UUID + lock primitives + audit fields; 2 vị trí schema đồng nhất | C4) Mô hình dữ liệu | BE, QA |
DEC-029: Thêm action get_customer_km2_balance realtime cho UI auto-fill + chip eligibility check | 5.3.1 Metadata Hasura — Action: get_customer_km2_balance | BE, FE |
Update FORMULA-001: source = available_amount từ action mới (KHÔNG dùng wallet.amount) | C3) Quy tắc / Công thức | BE, FE |
Update pseudocode DeductKM2Payment: payment_attempt_id UUID + status enum processing/completed/failed/expired + cross-check verify (order_id, customer_id, user_id) | C5.4 | BE |
| Lint vietnamese clean: chuẩn hoá calque headings + section title sang tiếng Việt canonical | Toàn file | None |
Đặc tả kỹ thuật (Dev Spec) — Ví KM 2 (Promotion Wallet 2)
Phiên bản: 1.11 Tham chiếu: PRD v1.11 + SOURCE_OF_TRUTH v1.6 | Ngày: 15/05/2026
File này dùng để làm gì: chốt contract kỹ thuật cho data model, Hasura metadata, Go action, frontend integration, bảo mật, NFR và task triển khai của
Ví KM 2. Nên đọc trước:C1) Phạm vi->C4) Mô hình dữ liệu->C5) Quy ước tích hợp->C8) Bảo mật->C12) Truy vết.Quy tắc ưu tiên: nếu có khác biệt với
SOURCE_OF_TRUTH.md, ưu tiênSOURCE_OF_TRUTH.md. Day-1 lock:wallet_km2_configrollout theoglobal-first, migration seeddisabled=true;branch_idchỉ là cột reserve cho phase sau, chưa phải contract UI/query chính thức ở v1.
Đầu vào chuẩn (Canonical Inputs)
| File | Vai trò |
|---|---|
| SOURCE_OF_TRUTH.md | Nguồn sự thật chuẩn + khóa giải pháp |
| EVIDENCE_PACK.md | Bằng chứng code/screen/db/config thật |
| prd.md | Quy ước nghiệp vụ, DEC, FR/AC và công thức A10 |
| decision-brief.md | Cửa vào package, chỉ tóm tắt và trỏ sang file chịu trách nhiệm |
| Design doc | Design intent ban đầu, không thắng SOURCE_OF_TRUTH.md |
C1) Phạm vi
Modules ảnh hưởng
| Module | Loại | Thay đổi |
|---|---|---|
wallet-api | BE | Lot creation (transaction_insert), FIFO deduction, expiry scheduler, refund_km2_wallet handler |
ecommerce-api | BE | Payment max% validation, order confirm balance check, invoice KM2 amount, delivery refund |
pkg/store | BE (shared) | Wallet type constants, payment method mapping, invoice struct, invoice payment skip list |
controller (Hasura) | DB/Metadata | 4 tables mới, 3 ALTER, permissions, cron trigger, relationships |
settings | FE | Config Ví KM 2, mở rộng PrepaidCardForm |
ecommerce | FE | Payment method wallet_promotion_2, auto-fill max%, toggle KM1+KM2 |
user | FE | Tab Ví KM 2 trong profile khách, danh sách lần mua Ví KM2, cảnh báo hết hạn |
report | FE | Dashboard báo cáo Ví KM 2 (Phase 3) |
Không thuộc phạm vi Đặc tả kỹ thuật
- Push notification nhắc hết hạn (backlog — cần tích hợp OneSignal)
- Khách tự mua Gói Ví KM2 trên app (backlog — cần thêm flow checkout app)
- % max per Gói Ví KM2 (backlog — hiện chỉ 1 config chung)
- Diva Partner app (chỉ Admin web)
C2) Tóm tắt ảnh hưởng
| Thành phần | Đã có | Cần thêm/sửa | Rủi ro |
|---|---|---|---|
wallet_type + payment_gateway | VND, VND_PROMOTION | INSERT VND_PROMOTION_2 + payment_gateway mapping | Low — seed data bổ sung |
prepaid_card table | Table hiện có | ALTER ADD wallet_target, expiry_months | Low — cột bổ sung |
product / service tables | Table hiện có | ALTER ADD allow_promo_wallet_2 | Low — cột bổ sung |
wallet_km2_lot | Không có | CREATE TABLE mới + 3 indexes | Low — table mới |
wallet_km2_payment_attempt | Không có | CREATE TABLE mới làm idempotency header cho payment attempt | Medium — bảo vệ retry/double submit |
wallet_km2_lot_deduction | Không có | CREATE TABLE mới + indexes theo lot/order/payment attempt | Low — table mới |
wallet_km2_config | Không có | CREATE TABLE mới. Day-1 chỉ dùng 1 record global (branch_id IS NULL); branch override để phase sau nếu UAT xác nhận cần | Low — table mới |
pkg/store/wallet.go | Hardcode IN list VND, VND_PROMOTION | Thêm VND_PROMOTION_2 vào 2 hàm | High — logic chia sẻ nhiều flow |
pkg/store/invoice_payment.go | Hardcode skip list | Thêm "wallet_promotion_2" vào skip list (line 80, 91, 140). Logic kế toán: phần KM2 trả KHÔNG tính actual_revenue, KHÔNG tính customer points, KHÔNG tạo fund. Giống behavior wallet/wallet_promotion hiện tại. Xem PRD FR-003 + FR-004 bảng kế toán. | Medium — ảnh hưởng tính commission, revenue, fund |
pkg/store/invoice.go | Invoice struct | Thêm WalletPromotion2Amount field | Medium — struct được dùng rộng rãi |
ecommerce-api/action/payment_order.go | Payment flow | Thêm max% validation, FIFO deduction, deduction records | High — core payment logic |
ecommerce-api/action/order_confirm.go | Balance check | Thêm VND_PROMOTION_2 balance check | Medium — order confirmation flow |
ecommerce-api/event/invoice_insert_update.go | Wallet transaction logic | Thêm wallet_promotion_2_amount handling | Medium — event chain |
ecommerce-api/event/delivery_update_status.go | Refund logic | Thêm refund KM2 vào lot cũ | Medium — refund flow |
wallet-api/event/transaction_insert.go | Topup VND/VND_PROMOTION split (base→VND, bonus→KM1) | Rẽ nhánh VND_PROMOTION_2: TOÀN BỘ value_into_wallet vào KM2 (không chia VND+KM1). Tạo N lots với snapshot (DEC-016). Multi-payment: lần đầu tạo lot, lần sau update credited_amount (DEC-019). ZNS mới. | Medium — event handler |
wallet-api/action/ (MỚI) | Không có | ★ DEC-018: Tạo action handler deduct_km2_payment dùng direct PostgreSQL tx (sql.DB), KHÔNG qua Hasura GraphQL. Lý do: Hasura không hỗ trợ SELECT FOR UPDATE cho FIFO atomic deduction. | High — core payment logic mới |
wallet-api/action/deduct_km2_payment.go | Không có | ★ DEC-022 + DEC-028: Thêm idempotency theo payment_attempt_id UUID (UNIQUE) + distributed lock 90s + cross-check verify (order_id, customer_id, user_id); guard expired_at > NOW() trong transaction | High — chống double submit/retry/scheduler miss |
wallet-api/scheduler/event.go | Placeholder | Thêm expire-km2-lots handler | Low — handler mới |
ecommerce-api/action/payment_order.go | Payment flow | ★ DEC-017: Ẩn option trả góp khi đơn có Gói Ví KM2. Gọi action deduct_km2_payment thay vì xử lý trực tiếp | High — core payment |
| FR-012 — Hiển thị KM2 trong đơn hàng hiện có: | |||
ecommerce-api/action/print_invoice_popup.go | Switch case "wallet", "wallet_promotion" (dòng 177, 269, 320) | Thêm case "wallet_promotion_2" → label "Ví KM 2" | Medium — hoá đơn in |
ecommerce-api/action/print_invoice_preview.go | Tương tự print_invoice_popup | Thêm case "wallet_promotion_2" | Medium |
pkg/store/notification_queue.go | Switch case payment method (dòng 445) | Thêm "wallet_promotion_2" → return "Ví KM 2" | Low |
FE: PrepaidOrderPayments.tsx | Summary: "Nạp vào ví DIVA" + "Nạp vào ví KM" | Thêm dòng "Nạp vào ví KM 2" conditional (> 0) | Low — thêm 1 dòng |
FE: PrepaidOrderPaymentTable.tsx | Bảng: cột "Ví DIVA" + "Ví KM" | Thêm cột "Ví KM 2" conditional (giống SCR-03) | Medium — conditional column |
FE: OrderPayments.tsx (sub_invoices expand) | Payment method label mapping | Thêm wallet_promotion_2 → icon + "Ví KM 2" | Low — label mapping |
| FR-012 — Đơn mỹ phẩm (Phase 1): | |||
FE: CosmeticOrderFormPayment.tsx | invoiceIntoPromotionWallet | Thêm invoiceIntoPromotion2Wallet cho KM2 | Medium |
FE: CosmeticOrderFormPaymentTable.tsx | wallet_promotion_amount | Thêm wallet_promotion_2_amount | Medium |
FE: CosmeticOrderPaymentFormMultiple.tsx | wallet_promotion_amount | Thêm KM2 vào thanh toán nhiều lần | Medium |
| FR-012 — Đơn sản phẩm (Phase 1): | |||
FE: ProductOrderCreate.tsx | customerWalletValue inject | Thêm VND_PROMOTION_2 vào inject | Low |
FE: ProductOrderItems.tsx | allow_promo_wallet check | Thêm allow_promo_wallet_2 check | Low |
| FR-012 — Invoice template (Phase 1): | |||
FE: InvoiceTemplatePopupPrint.tsx | Payment method display | Thêm wallet_promotion_2 label | Low |
FE: InvoiceTemplatePreview.tsx | Payment method display | Thêm wallet_promotion_2 label | Low |
| FR-012 — Fund/Quỹ (Phase 1): | |||
FE: FundTable.tsx | wallet_promotion_amount | Thêm wallet_promotion_2_amount | Low |
FE: FundInvoicePopup.tsx | wallet_promotion_amount | Thêm wallet_promotion_2_amount | Low |
| FR-012 — Withdraw/Refund (Phase 2): | |||
FE: WithdrawRequestDetail.tsx | Payment method label | Thêm wallet_promotion_2 → "Ví KM 2" | Low |
| FR-012 — Report DV/NV (Phase 2): | |||
FE: ServiceReportTable.tsx + ServiceGroupReportTable.tsx | wallet_promotion_revenue | Thêm cột wallet_promotion_2_revenue | Medium |
FE: EmployeeRevenueReportDoughnutChart.tsx | total_wallet_promotion_amount | Thêm KM2 vào biểu đồ | Medium |
BE: report_service.graphql + report_employee_revenue.graphql | Query fields | Thêm KM2 fields | Medium |
| FR-012 — CRM Customer (Phase 2): | |||
FE: CustomerEWalletInformation.tsx | Wallet display | Thêm số dư Ví KM 2 | Low |
C2.1 Ma trận impact theo tính năng hiện hữu
| Tính năng hiện hữu | File/contract cần chạm | Delta triển khai cho KM2 | Không chạm |
|---|---|---|---|
| KM1 wallet/payment | pkg/store/wallet.go, invoice_payment.go, payment_method, wallet_type | Thêm VND_PROMOTION_2 / wallet_promotion_2, mapping balance/skip list riêng | Không đổi VND_PROMOTION, wallet_promotion, dữ liệu KM1 |
| Settings Ví khuyến mãi | PromotionWallet.tsx, wallet_km2_config, payment_method | Thêm block config KM2 + readiness; seed disabled=true | Không tạo route settings mới |
| Thẻ trả trước | prepaid_card, prepaid_card_view, PrepaidCardForm.tsx | Thêm wallet_target, expiry_months; validate Gói Ví KM2 | Không đổi logic thẻ VND/KM1 |
| Đơn nạp tiền | PrepaidOrderCreate.tsx, PrepaidOrderForm*, transaction_insert.go, invoice_insert_update.go | Cho bán Gói Ví KM2, summary KM2, tạo/update lot sau thanh toán, cấm trả góp | Không tạo checkout app/customer self-serve |
| Thanh toán đơn DV/SP | payment_order.go, order_confirm.go, OrderPayments.tsx, ProductOrderCreate.tsx | Thêm payment method KM2, max%, eligibility, action deduct_km2_payment, idempotency | Không đổi flow tiền mặt/chuyển khoản/thẻ/KM1 |
| Product/Service detail | ProductDetail.tsx, ServiceDetailDetail.tsx, DB product/service | Thêm allow_promo_wallet_2 và permission internal_configuration:update | Không dùng allow_promo_wallet cho KM2 |
| Customer profile | CustomerDetail.tsx, CustomerEWalletInformation.tsx, action get_customer_km2_lots | Thêm danh sách lần mua Ví KM2, expiry, audit, CTA Hoàn ví KM2; backend enforce customer scope | Không cấp direct select wallet_km2_lot cho role user |
| Yêu cầu hoàn tiền | WithdrawRequestCreate/Detail/Table, WithdrawForm, transaction_request, changeStatusTransaction, handler refund_km2_wallet | Thêm behavior refund_km2_wallet, label "Hoàn ví KM2", fields lot_id, km2_deduct_amount, suggested_refund_amount, refund_method, policy_snapshot, filter/list/detail/approve/payment | Không dựng approval module riêng; không dùng tên legacy cũ |
| Refund đơn đã dùng KM2 | delivery_update_status.go, wallet_km2_lot_deduction | Query deduction để hoàn đúng lot; expired thì gia hạn, refunded thì tạo lot mới | Không hoàn KM2 về VND hoặc VND_PROMOTION |
| Hoá đơn/print/template | print_invoice_popup.go, print_invoice_preview.go, InvoiceTemplatePopupPrint.tsx, InvoiceTemplatePreview.tsx | Thêm label/amount/code "Ví KM2"; biến mới nếu cần | Không đổi biến KM1 hiện hữu |
| Fund/actual revenue/ERP/export | FundTable.tsx, FundInvoicePopup.tsx, report_sales_revenue.go, export_report_erp.go, report SQL/functions | Thêm field KM2 riêng; KM2 paid amount thuộc nhóm wallet-paid, không tính actual revenue/fund; bán Gói Ví KM2 tính theo giá mua gói trả thật | Không cộng value_into_wallet vào thực thu |
| Report DV/NV/prepaid | report_service.graphql, report_employee_revenue.graphql, PrepaidCardReport*, service report components | Thêm wallet_promotion_2_*, label/cột riêng, export riêng | Không đổi nghĩa wallet_promotion_* của KM1 |
| Notification/ZNS/SMS | notification_queue.go, transaction_insert.go, notification migrations/templates | Thêm biến/template KM2 nếu cần thông báo nạp Gói Ví KM2, thanh toán, expiry | Không reuse wallet_promotion_amount cho KM2 |
| Permission/Auth | auth/server/config.go, module_permission_action, role_module.actions, Hasura metadata | Map operations theo Dynamic Permission v2 cho config/prepaid/payment/customer/refund/report | Không hard-code quyền theo role name trong handler |
C2.2 Tính năng không mở rộng trong Day-1
| Tính năng | Kết luận | Dev note |
|---|---|---|
| Rank/loyalty | Không thêm rule hạng riêng | Chỉ bảo đảm payment method KM2 nằm trong skip list giống wallet/KM1 để không tính points/actual revenue |
| Appointment booking | Không đổi lịch hẹn/trạng thái lịch | Chỉ phát sinh ảnh hưởng khi lịch hẹn đã thành order và thanh toán bằng KM2 |
| Kho/tồn kho/giá vốn | Không đổi stock movement | KM2 là payment method, không phải item/stock |
| CRM assignment/pipeline | Không đổi owner/pipeline/chăm sóc | Chỉ thêm hiển thị KM2 khi pass customer permission |
| Affiliate/referral/CMS gift/event | Không dùng KM2 làm reward/voucher | Không thêm wallet receive type hoặc campaign gift bằng KM2 trong scope này |
| Chấm công/HR cơ bản | Không đổi rule chấm công/lịch làm | Employee revenue report có thể thêm cột KM2 nhưng không đổi timekeeping/salary base |
C3) Quy tắc và công thức triển khai (chỉ phần triển khai)
Định nghĩa nghiệp vụ (tên, mô tả, công thức, ví dụ, edge cases) -> xem PRD A10. Section này chỉ chứa implementation delta mà Dev cần.
RULE-COMM-001: KM 2 inherit cơ chế affiliate_config của KM 1 (DEC-030)
- Ref: PRD DEC-030 +
discussion-commission-affiliate.md - Hành vi inherited: KM 2 dùng cùng gate
affiliate_confignhư KM 1 (gate ởservices/ecommerce-api/event/invoice_insert_update.go:1287-1298—InsertInvoiceAffiliate). Kết quả commission per invoice tuân theo trạng thái ô matrix(order_kind, payment_method_id)tại thời điểm invoice complete — không có override cứng trong code Go. - Default seed (Migration 3): 3 row
(service|cosmetic|prepaid, wallet_promotion_2, FALSE)để đồng bộ với cấu hình production Diva hiện hành (6 ô(service|cosmetic|prepaid) × (wallet|wallet_promotion)đang FALSE). - Cơ chế MIX payment: Khi khách dùng MIX (1 phần cash + 1 phần KM 2) → invoice tách thành 2 sub-invoice với
payment_method_idkhác nhau. GateInsertInvoiceAffiliatechạy per sub-invoice → kết quả commission của từng phần phụ thuộc ô matrix tương ứng. Pattern hiện hữu, không cần code mới. - Admin có quyền điều chỉnh: UI matrix
AffiliateConfiguration.tsxtự render cột "VÍ KHUYẾN MÃI 2" sau Migration 3 — admin tick/untick từng ô qua UI nếu muốn thay đổi policy. - Implementation delta cho KM 2: Chỉ cần Migration 3 (xem C7) seed 3 row. KHÔNG touch code Go handler.
- QA gate: TC-012-COMM-KM2-OFF + TC-012-COMM-KM2-CASH + TC-012-COMM-KM2-MIX + TC-012-COMM-KM2-ADMIN-OVERRIDE (xem
qa-test-plan.mdD4).
FORMULA-001: Số tiền Ví KM 2 thanh toán (per đơn hàng)
Tham chiếu: PRD A10 FORMULA-001 + DEC-029
Quy tắc nguồn balance (DEC-029): FE auto-fill + chip eligibility check PHẢI lấy balance từ action
get_customer_km2_balance(realtime từ active lot cóexpired_at > NOW()), KHÔNG đọcwallet.amounttrực tiếp.wallet.amountchỉ là cached value cho report/KPI/dashboard.SQL trong handler
deduct_km2_payment(BE, KHÔNG phải FE):
sql
-- Bước 1: Tính eligible_total
SELECT COALESCE(SUM(oi.price * oi.quantity), 0) AS eligible_total
FROM order_item oi
JOIN product p ON p.id = oi.product_id AND p.deleted_at IS NULL
WHERE oi.order_id = $order_id
AND p.allow_promo_wallet_2 = TRUE
AND oi.deleted_at IS NULL;
-- Bước 2: Lấy config Day-1 (global-first)
SELECT max_percent_per_order FROM wallet_km2_config
WHERE branch_id IS NULL
AND disabled = FALSE AND deleted_at IS NULL
LIMIT 1;
-- Bước 3: Lấy available balance realtime (KHÔNG từ wallet.amount)
SELECT COALESCE(SUM(balance), 0) AS available_amount
FROM wallet_km2_lot
WHERE user_id = $customer_id
AND status = 'active'
AND expired_at > NOW()
AND deleted_at IS NULL;
-- Bước 4: Tính kết quả
-- km2_pay = MIN(eligible_total * max_percent_per_order / 100, available_amount)FE flow (auto-fill chip Ví KM 2):
- NV mở payment screen → FE gọi action
get_customer_km2_balance(customer_id)→ nhậnavailable_amount - FE gọi mutation/query
compute_order_eligible_total(order_id)(hoặc tính từ order data đã load) → nhậneligible_total - FE load
wallet_km2_config.max_percent_per_order(đã cache khi vào POS) - FE tính
km2_pay = MIN(eligible_total * max_percent_per_order / 100, available_amount)→ auto-fill chip
- NV mở payment screen → FE gọi action
Mapping nguồn:
eligible_total← SUM(order_item.price * order_item.quantity) WHEREproduct.allow_promo_wallet_2 = TRUEmax_percent_per_order←wallet_km2_config.max_percent_per_orderavailable_amount← Actionget_customer_km2_balance.available_amount=SUM(wallet_km2_lot.balance)WHEREstatus='active' AND expired_at > NOW()(DEC-029: realtime, không dùngwallet.amount)
Độ chính xác: VND (int8), không làm tròn — phép tính integer division:
eligible_total * max_percent_per_order / 100Chỉ mục:
idx_km2_lot_fifopartial(user_id, activated_at) WHERE status='active'cover được Bước 3Ghi chú hiệu năng: Query
get_customer_km2_balance< 5ms với 10 lots/khách (case thực tế). Backenddeduct_km2_paymenttính lại trong cùng transaction để đảm bảo atomic; FE chỉ dùng để auto-fill hiển thị, không phải authoritative.
FORMULA-002: Gợi ý số tiền hoàn ví KM2 (auto-fill, approver sửa được)
- Tham chiếu: PRD A10 FORMULA-002
- Mục đích: FE auto-fill 2 field: "Số tiền KM2 trừ" + "Số tiền hoàn khách". Approver có quyền
refund_request_management_submenu:updatesửa được cả 2; bước duyệt dùngapprove, bước chi tiền dùngpayment. - Go handler (
refund_km2_walletbehavior/action):
go
// Auto-fill gợi ý — FE gọi trước khi tạo yêu cầu Hoàn ví KM2
// ★ DEC-016: dùng snapshot từ lot, KHÔNG query prepaid_card cross-DB
suggestedKM2Deduct := lot.Balance // default = toàn bộ balance
refundRatio := float64(lot.PackagePrice) / float64(lot.WalletValue)
refundFee := lot.PackagePrice * config.RefundFeePercent / 100
suggestedRefund := max(int64(float64(suggestedKM2Deduct) * refundRatio) - refundFee, 0)
// Approver submit: km2Deduct + customerRefund (có thể khác suggested)
// Validation:
// 0 < km2Deduct <= lot.Balance
// 0 <= customerRefund <= totalPaidAmount (tổng đã trả cho đơn mua Gói Ví KM2)- Mapping nguồn (★ DEC-016 snapshot — không cross-DB):
refund_ratio←wallet_km2_lot.package_price/wallet_km2_lot.wallet_valuerefund_fee←wallet_km2_lot.package_price×wallet_km2_config.refund_fee_percent/ 100
- Độ chính xác: Go float64 cho ratio, kết quả cast int64. MIN = 0.
- Partial refund: Nếu km2Deduct < balance → lot vẫn
active(balance giảm). Full refund →refunded.
FORMULA-003: Tỉ lệ sử dụng Ví KM2
- Tham chiếu: PRD A10 FORMULA-003
- SQL (Hasura query / SQL function cho report):
sql
SELECT
COALESCE(SUM(used_amount), 0) AS total_used,
COALESCE(SUM(initial_amount), 0) AS total_deposited,
CASE
WHEN SUM(initial_amount) = 0 THEN NULL
ELSE ROUND(SUM(used_amount)::NUMERIC / SUM(initial_amount) * 100, 2)
END AS redemption_rate
FROM wallet_km2_lot
WHERE status IN ('active', 'exhausted', 'expired')
AND deleted_at IS NULL
AND branch_id = $branch_id -- Manager scope, bỏ cho Admin
AND activated_at BETWEEN $from_date AND $to_date;- Mapping nguồn:
total_used<- SUM(wallet_km2_lot.used_amount)total_deposited<- SUM(wallet_km2_lot.initial_amount)
- Độ chính xác: ROUND 2 decimal. NULL khi denominator = 0 -> FE hiển thị "—"
- Chỉ mục:
idx_wallet_km2_lot_user_statuscover được query này - Ghi chú hiệu năng: Report query có thể chậm khi > 10K lots. Nếu cần, tạo materialized view hoặc cache Redis.
FORMULA-FIFO: FIFO Deduction (không có trong PRD A10 — logic thuần BE)
- Tham chiếu: DEC-018 — dùng Go action handler + direct PostgreSQL connection (KHÔNG qua Hasura GraphQL)
- Mô tả: Trừ tiền từ các lot theo thứ tự cũ nhất trước (activated_at ASC). Toàn bộ trong 1 SQL transaction.
- Architecture: Tạo Hasura Action
deduct_km2_payment→ handler trongwallet-api/action/. Handler dùngsql.DBdirect connection (KHÔNG dùngctx.AdminClientHasura GraphQL vì Hasura không hỗ trợSELECT FOR UPDATE). - Idempotency (DEC-028): input bắt buộc có
payment_attempt_id(UUID v4 client gen). Backend tạo/lock idempotency header trongwallet_km2_payment_attempttheo unique keypayment_attempt_id; cross-check verify(order_id, customer_id, user_id)để chống bypass FE. Retry cùng key trả lại kết quả cũ trongresult(replay), không trừ ví lần 2.wallet_km2_lot_deductionvẫn được phép có nhiều dòng cho cùng payment attempt, mỗi dòng tương ứng một lot bị trừ. - Expiry guard: action chỉ lock lot
status = 'active'vàexpired_at > NOW(). Nếu scheduler miss, lot quá hạn vẫn bị loại khỏi FIFO. - Pseudocode (Go):
go
// Handler: POST /actions/deduct_km2_payment
// Input: user_id (NV submit), customer_id (khách bị trừ), order_id, amount, payment_attempt_id
// ★ DEC-018: Dùng direct PostgreSQL connection, KHÔNG qua Hasura GraphQL
// ★ DEC-028: Idempotency key = payment_attempt_id (UNIQUE); cross-check (order_id, customer_id, user_id)
func (h *Handler) DeductKM2Payment(c *gin.Context) {
tx, _ := h.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
defer tx.Rollback()
// 0. Idempotency guard — UPSERT lock header theo payment_attempt_id (DEC-028)
// + cross-check verify (order_id, customer_id, user_id) khớp record cũ
attempt := upsertPaymentAttemptLock(tx, paymentAttemptID, orderID, customerID, userID, amount)
if attempt.Status == "completed" {
return attempt.Result // replay: trả lại kết quả cũ, không trừ lần 2
}
if attempt.Status == "processing" && attempt.LockExpiresAt.After(time.Now()) {
return errors.New("409 attempt_in_progress") // duplicate đang xử lý
}
// 1. Lock lots — SELECT FOR UPDATE đảm bảo atomic
lots, _ := tx.Query(`
SELECT id, balance, credited_amount, used_amount, status, expired_at
FROM wallet_km2_lot
WHERE user_id = $1
AND status = 'active'
AND expired_at > NOW()
AND deleted_at IS NULL
ORDER BY activated_at ASC
FOR UPDATE`, userID)
remaining := amount
var deductions []LotDeduction
for _, lot := range lots {
if remaining <= 0 { break }
deduct := min(remaining, lot.Balance)
remaining -= deduct
// 2. Update lot
tx.Exec(`UPDATE wallet_km2_lot
SET used_amount = used_amount + $1,
balance = balance - $1,
status = CASE WHEN balance - $1 = 0 THEN 'exhausted' ELSE status END,
updated_at = NOW()
WHERE id = $2`, deduct, lot.ID)
// 3. Ghi deduction record
deductions = append(deductions, LotDeduction{
LotID: lot.ID, OrderID: orderID, PaymentAttemptRecordID: attempt.ID,
PaymentAttemptID: paymentAttemptID, Amount: deduct, Type: "deduction",
})
}
if remaining > 0 {
return ErrInsufficientKM2Balance // rollback tự động
}
// 4. Batch insert deductions
insertDeductions(tx, deductions)
// 5. Update wallet.amount
tx.Exec(`UPDATE wallet SET amount = amount - $1
WHERE user_id = $2 AND wallet_type_id = 'VND_PROMOTION_2'`, amount, userID)
markPaymentAttemptSucceeded(tx, attempt.ID, amount, deductions)
tx.Commit()
}- Race condition:
SELECT ... FOR UPDATE+sql.LevelReadCommittedđảm bảo atomic. Request thứ 2 chờ lock release. - Partial failure: Nếu fail giữa chừng →
tx.Rollback()tự động, không có partial deduction. - Retry/double submit: retry cùng
payment_attempt_idkhông tạo deduction mới; retry khác id phải validate lại balance/lot như request mới.
FORMULA-004: Tiền vào Ví KM 2 per lần thanh toán (multi-payment)
- Tham chiếu: PRD A10 FORMULA-004, DEC-019
- Mô tả: Mỗi lần trả tiền cho đơn mua Gói Ví KM2 → ví nhận theo tỉ lệ. Lot tạo ngay lần trả đầu.
- SQL (trong transaction_insert event handler):
go
// ★ DEC-019: Khi wallet_target = VND_PROMOTION_2
// Tính credit cho lần trả này
credit := (customerPaidAmount * totalValueIntoWallet) / totalOrderAmount
// Lần trả cuối: dùng phép trừ tránh sai lệch làm tròn
if isLastPayment {
credit = totalValueIntoWallet - sumPreviousCredits
}
// Lần trả đầu tiên → tạo N lots (N = qty)
if isFirstPayment {
for i := 0; i < qty; i++ {
createLot(Lot{
UserID: userID,
BranchID: branchID,
PrepaidCardID: prepaidCardID,
PackageName: prepaidCard.Name, // ★ DEC-016 snapshot
PackagePrice: prepaidCard.Value, // ★ DEC-016 snapshot
WalletValue: prepaidCard.ValueIntoWallet, // ★ DEC-016 snapshot
InitialAmount: prepaidCard.ValueIntoWallet, // target full amount
CreditedAmount: creditPerLot, // proportional
Balance: creditPerLot,
Status: "active",
ActivatedAt: now,
ExpiredAt: endOfDay(now.AddDate(0, expiryMonths, 0)),
})
}
} else {
// Lần trả tiếp theo → update credited_amount + balance cho các lots
updateLotsCredit(orderID, creditPerLot)
}creditPerLot=credit / qty(chia đều cho N lots trong đơn)
C4) Mô hình dữ liệu
Bảng hiện có (chỉ đọc)
Database: wallet
| Table | Dùng cho | Key columns |
|---|---|---|
wallet | Số dư ví của khách | user_id, wallet_type_id, amount |
wallet_type | Danh sách loại ví | id (VND, VND_PROMOTION, ...) |
transaction | Lịch sử giao dịch ví | wallet_id, amount, behavior_id |
transaction_request | Request giao dịch (trigger event) | behavior_id, wallet_type_id, metadata |
transaction_request_user | User mapping cho transaction | user_id, wallet_type_id |
Database: ecommerce
| Table | Dùng cho | Key columns |
|---|---|---|
order | Đơn hàng | id, user_id, branch_id, total |
order_item | Chi tiết đơn hàng | order_id, product_id, price, quantity |
invoice | Hoá đơn thanh toán | order_id, payment_method |
prepaid_card | Thẻ trả trước | id, value, value_into_wallet |
product | Sản phẩm | id, allow_promo_wallet |
service | Dịch vụ | id, allow_promo_wallet |
payment_gateway | Phương thức thanh toán | id, name |
payment_gateway_wallet_type | Mapping gateway <-> wallet type | payment_gateway_id, wallet_type_id |
Bảng mới
1. wallet_km2_lot (Database: wallet)
sql
CREATE TABLE wallet_km2_lot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK -> user (khách sở hữu)
branch_id UUID NOT NULL, -- FK -> branch (chi nhánh bán Gói Ví KM2, lấy từ order.branch_id)
prepaid_card_id UUID NOT NULL, -- logical FK -> ecommerce.prepaid_card (KHÔNG có DB constraint — cross-DB)
order_id UUID NOT NULL, -- logical FK -> ecommerce.order (cross-DB)
-- ★ DEC-016: Snapshot giá mua gói tại thời điểm mua (tránh cross-DB join + data integrity)
package_name TEXT NOT NULL, -- snapshot prepaid_card.name
package_price INT8 NOT NULL, -- snapshot prepaid_card.value (giá bán Gói Ví KM2)
wallet_value INT8 NOT NULL, -- snapshot prepaid_card.value_into_wallet (giá trị ví)
initial_amount INT8 NOT NULL, -- target: wallet_value (full amount per lot)
credited_amount INT8 NOT NULL DEFAULT 0, -- ★ DEC-019: tổng đã nạp (tăng dần khi thanh toán nhiều lần)
used_amount INT8 NOT NULL DEFAULT 0, -- số tiền đã dùng (tăng dần khi thanh toán DV)
balance INT8 NOT NULL DEFAULT 0, -- số dư = credited - used
CONSTRAINT chk_lot_balance CHECK (balance = credited_amount - used_amount),
CONSTRAINT chk_lot_balance_non_negative CHECK (balance >= 0),
CONSTRAINT chk_credited_le_initial CHECK (credited_amount <= initial_amount),
status TEXT NOT NULL DEFAULT 'active',
-- active / exhausted / expired / refunded
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- ★ DEC-019: ngày trả lần đầu, expiry tính từ đây
expired_at TIMESTAMPTZ NOT NULL, -- end_of_day(activated_at + expiry_months)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
-- FIFO query: user + status + sắp xếp theo activated_at
CREATE INDEX idx_wallet_km2_lot_user_status
ON wallet_km2_lot (user_id, status, activated_at ASC)
WHERE deleted_at IS NULL;
-- Expiry scheduler: tìm lots hết hạn
CREATE INDEX idx_wallet_km2_lot_expiry
ON wallet_km2_lot (status, expired_at)
WHERE status = 'active' AND deleted_at IS NULL;
-- Report theo chi nhánh
CREATE INDEX idx_wallet_km2_lot_branch
ON wallet_km2_lot (branch_id)
WHERE deleted_at IS NULL;DEC-016:
package_name,package_price,wallet_valuelà snapshot từprepaid_cardtại thời điểm mua. Query lot KHÔNG cần join cross-DB sang ecommerce. Nếu Admin sửa prepaid_card sau khi bán → lot không bị ảnh hưởng.DEC-019:
credited_amounttăng dần khi thanh toán nhiều lần.balance = credited_amount - used_amount. Khi trả đủ:credited_amount = initial_amount.
2. wallet_km2_payment_attempt (Database: wallet)
DEC-028 (15/05/2026): Schema chốt merge — kết hợp audit fields (Diva pattern) với lock primitives (TG-003 idempotency contract). PK riêng
id; UNIQUE riêngpayment_attempt_idđểON CONFLICThoạt động độc lập.
sql
CREATE TABLE wallet_km2_payment_attempt (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_attempt_id UUID NOT NULL, -- idempotency key (client gen UUID v4)
order_id UUID NOT NULL, -- đơn hàng thanh toán
user_id UUID NOT NULL, -- NV submit (employee, cross-check verify)
customer_id UUID NOT NULL, -- khách bị trừ ví (cross-check verify)
requested_amount INT8 NOT NULL, -- số tiền caller yêu cầu trừ
deducted_amount INT8 NOT NULL DEFAULT 0, -- số tiền BE thực sự trừ (≤ requested)
status TEXT NOT NULL DEFAULT 'processing',
-- processing / completed / failed / expired
result JSONB, -- response cache cho replay duplicate
error JSONB, -- error payload cho retry nếu fail
request_hash TEXT, -- detect retry cùng key nhưng khác payload
lock_holder UUID, -- worker UUID đang giữ lock (chống concurrent retry)
lock_expires_at TIMESTAMPTZ, -- lock TTL (mặc định 90s từ requested_at)
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- mốc tính TTL dedup window (7 ngày)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
-- Idempotency: 1 payment_attempt_id chỉ tồn tại 1 lần (cross-order vẫn unique để chống bypass FE)
CREATE UNIQUE INDEX uq_km2_payment_attempt_id
ON wallet_km2_payment_attempt (payment_attempt_id)
WHERE deleted_at IS NULL;
-- Tra cứu batch theo đơn hàng
CREATE INDEX idx_km2_payment_attempt_order
ON wallet_km2_payment_attempt (order_id)
WHERE deleted_at IS NULL;
-- Lock TTL: cron quét lock expired để rollback record về 'failed'
CREATE INDEX idx_km2_payment_attempt_lock
ON wallet_km2_payment_attempt (lock_expires_at)
WHERE deleted_at IS NULL AND status = 'processing';
-- TTL cleanup: cron 'nightly' xoá record completed/failed > 7 ngày
CREATE INDEX idx_km2_payment_attempt_ttl
ON wallet_km2_payment_attempt (requested_at)
WHERE deleted_at IS NULL AND status IN ('completed', 'failed');DEC-022: Idempotency nằm ở batch/header
wallet_km2_payment_attempt, không nằm trực tiếp trên từng deduction row. Một payment attempt có thể trừ nhiều lot và vẫn retry an toàn.DEC-028 ghi chú schema:
idlà PK technical riêng để tương lai có thể đổi key idempotency mà không phải đổi FK. Mọi handler/test/QA join quapayment_attempt_id(UNIQUE) hoặcid(PK) — KHÔNG dùng composite(order_id, payment_attempt_id)như schema cũ.
3. wallet_km2_lot_deduction (Database: wallet)
sql
CREATE TABLE wallet_km2_lot_deduction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lot_id UUID NOT NULL, -- FK -> wallet_km2_lot
order_id UUID NOT NULL, -- đơn hàng sử dụng/refund
payment_attempt_record_id UUID, -- FK -> wallet_km2_payment_attempt cho type='deduction'
payment_attempt_id TEXT, -- idempotency key cho deduction payment retry/double submit
transaction_id UUID, -- FK -> transaction (wallet DB)
amount INT8 NOT NULL, -- số tiền trừ (dương) hoặc hoàn (âm)
type TEXT NOT NULL DEFAULT 'deduction',
-- deduction: trừ khi thanh toán
-- refund: hoàn khi refund đơn DV
-- expiry_cancel: huỷ khi lot hết hạn
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
deleted_at TIMESTAMPTZ,
CONSTRAINT fk_lot_deduction_lot
FOREIGN KEY (lot_id) REFERENCES wallet_km2_lot(id),
CONSTRAINT fk_lot_deduction_attempt
FOREIGN KEY (payment_attempt_record_id) REFERENCES wallet_km2_payment_attempt(id)
);
-- Query deductions per lot
CREATE INDEX idx_lot_deduction_lot
ON wallet_km2_lot_deduction (lot_id)
WHERE deleted_at IS NULL;
-- Query deductions per order (cho refund)
CREATE INDEX idx_lot_deduction_order
ON wallet_km2_lot_deduction (order_id)
WHERE deleted_at IS NULL;
-- Cho phép 1 payment attempt trừ nhiều lot, nhưng không duplicate cùng lot khi retry
CREATE UNIQUE INDEX uq_lot_deduction_payment_attempt_lot
ON wallet_km2_lot_deduction (payment_attempt_record_id, lot_id, type)
WHERE deleted_at IS NULL AND payment_attempt_record_id IS NOT NULL AND type = 'deduction';4. wallet_km2_config (Database: wallet)
sql
CREATE TABLE wallet_km2_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID, -- Day-1: luôn NULL. Reserve cho branch override phase sau
max_percent_per_order INT NOT NULL DEFAULT 20,
allow_combine_km1 BOOLEAN NOT NULL DEFAULT FALSE,
allow_refund BOOLEAN NOT NULL DEFAULT TRUE,
refund_fee_percent INT NOT NULL DEFAULT 20,
refund_deadline_days INT DEFAULT 30,
refund_extend_days INT NOT NULL DEFAULT 30,
disabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
-- Day-1 chỉ cho phép 1 global config row. UNIQUE(branch_id) không đủ vì PostgreSQL cho nhiều NULL.
CREATE UNIQUE INDEX uq_wallet_km2_config_global_row
ON wallet_km2_config ((branch_id IS NULL))
WHERE branch_id IS NULL AND deleted_at IS NULL;
-- Chỉ cho phép 1 global active config sau khi PO/Admin bật go-live.
CREATE UNIQUE INDEX uq_wallet_km2_config_global_active
ON wallet_km2_config ((branch_id IS NULL))
WHERE branch_id IS NULL AND disabled = FALSE AND deleted_at IS NULL;ALTER Tables hiện có
4. ALTER prepaid_card (Database: ecommerce)
sql
ALTER TABLE prepaid_card
ADD COLUMN wallet_target TEXT NOT NULL DEFAULT 'VND',
ADD COLUMN expiry_months INT;
COMMENT ON COLUMN prepaid_card.wallet_target IS 'VND (flow cũ) / VND_PROMOTION_2 (Ví KM 2)';
COMMENT ON COLUMN prepaid_card.expiry_months IS 'Số tháng hết hạn. NULL = không hạn. Chỉ áp dụng khi wallet_target = VND_PROMOTION_2';5. ALTER product + service (Database: ecommerce)
sql
ALTER TABLE product ADD COLUMN allow_promo_wallet_2 BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE service ADD COLUMN allow_promo_wallet_2 BOOLEAN NOT NULL DEFAULT FALSE;Dữ liệu seed
sql
-- Wallet type mới
INSERT INTO wallet_type (id, name, currency_code, promotion, "default", disabled)
VALUES ('VND_PROMOTION_2', 'Ví KM 2', 'VND', true, false, false);
-- Payment gateway mới
INSERT INTO payment_gateway (id, name, icon_url, web_visible, app_visible)
VALUES ('wallet_promotion_2', 'Ví KM 2', 'wallet_promotion_2.png', true, true);
-- Mapping gateway <-> wallet type
INSERT INTO payment_gateway_wallet_type (payment_gateway_id, wallet_type_id)
VALUES ('wallet_promotion_2', 'VND_PROMOTION_2');
-- Default config (toàn hệ thống, seed tắt; PO/Admin bật ở go-live)
INSERT INTO wallet_km2_config (branch_id, max_percent_per_order, allow_combine_km1, allow_refund, refund_fee_percent, refund_deadline_days, refund_extend_days, disabled)
VALUES (NULL, 20, FALSE, TRUE, 20, 30, 30, TRUE);Hàm SQL
Function: get_km2_report_summary (Database: wallet, Phase 3)
sql
CREATE OR REPLACE FUNCTION public.get_km2_report_summary(
p_branch_id UUID DEFAULT NULL,
p_from_date TIMESTAMPTZ DEFAULT NULL,
p_to_date TIMESTAMPTZ DEFAULT NULL
)
RETURNS TABLE (
total_lots BIGINT,
total_deposited BIGINT,
total_used BIGINT,
redemption_rate NUMERIC,
balance_expiring_30d BIGINT
)
LANGUAGE sql STABLE
AS $$
WITH filtered AS (
SELECT *
FROM wallet_km2_lot
WHERE deleted_at IS NULL
AND status IN ('active', 'exhausted', 'expired')
AND (p_branch_id IS NULL OR branch_id = p_branch_id)
AND (p_from_date IS NULL OR activated_at >= p_from_date)
AND (p_to_date IS NULL OR activated_at <= p_to_date)
),
expiring AS (
SELECT COALESCE(SUM(balance), 0) AS bal
FROM wallet_km2_lot
WHERE deleted_at IS NULL
AND status = 'active'
AND expired_at <= NOW() + INTERVAL '30 days'
AND (p_branch_id IS NULL OR branch_id = p_branch_id)
)
SELECT
COUNT(*)::BIGINT AS total_lots,
COALESCE(SUM(initial_amount), 0)::BIGINT AS total_deposited,
COALESCE(SUM(used_amount), 0)::BIGINT AS total_used,
CASE
WHEN SUM(initial_amount) = 0 THEN NULL
ELSE ROUND(SUM(used_amount)::NUMERIC / SUM(initial_amount) * 100, 2)
END AS redemption_rate,
(SELECT bal FROM expiring) AS balance_expiring_30d
FROM filtered;
$$;C5) Quy ước tích hợp
5.1 Metadata Hasura — Bảng mới
File: controller/metadata/databases/wallet/tables/public_wallet_km2_lot.yaml
yaml
table:
name: wallet_km2_lot
schema: public
object_relationships:
- name: prepaid_card
using:
manual_configuration:
remote_table:
name: prepaid_card
schema: public
column_mapping:
prepaid_card_id: id
array_relationships:
- name: deductions
using:
foreign_key_constraint_on:
column: lot_id
table:
name: wallet_km2_lot_deduction
schema: public
select_permissions:
- role: admin
permission:
columns: "*"
filter:
deleted_at: { _is_null: true }Security contract: không cấp direct
select_permissionscho roleusertrênwallet_km2_lot. Màn customer profile phải đọc qua backend action/query có kiểm tracustomer_management:access,customer_management:view_all,portalvàbranch_mode. Cross-branch redeem chỉ chạy trong Go action bằng service permission, không mở RLS toàn bảng.
File: controller/metadata/databases/wallet/tables/public_wallet_km2_payment_attempt.yaml
yaml
table:
name: wallet_km2_payment_attempt
schema: public
array_relationships:
- name: deductions
using:
foreign_key_constraint_on:
column: payment_attempt_record_id
table:
name: wallet_km2_lot_deduction
schema: public
select_permissions:
- role: admin
permission:
columns: "*"
filter:
deleted_at: { _is_null: true }Security contract: không cấp direct
select_permissionscho roleusertrênwallet_km2_payment_attempt. Idempotency result chỉ trả về qua actiondeduct_km2_paymentsau khi kiểm tra quyền đơn hàng/khách.
File: controller/metadata/databases/wallet/tables/public_wallet_km2_lot_deduction.yaml
yaml
table:
name: wallet_km2_lot_deduction
schema: public
object_relationships:
- name: lot
using:
foreign_key_constraint_on: lot_id
- name: payment_attempt
using:
foreign_key_constraint_on: payment_attempt_record_id
select_permissions:
- role: admin
permission:
columns: "*"
filter:
deleted_at: { _is_null: true }Security contract: không cấp direct
select_permissionscho roleusertrênwallet_km2_lot_deduction. Refund/payment audit chỉ trả về qua backend action/query sau khi kiểm tra quyền khách/đơn hàng.
File: controller/metadata/databases/wallet/tables/public_wallet_km2_config.yaml
yaml
table:
name: wallet_km2_config
schema: public
select_permissions:
- role: user
permission:
columns:
- id
- branch_id
- max_percent_per_order
- allow_combine_km1
- allow_refund
- refund_fee_percent
- refund_deadline_days
- refund_extend_days
- disabled
filter:
deleted_at: { _is_null: true }
- role: admin
permission:
columns: "*"
filter:
deleted_at: { _is_null: true }
insert_permissions:
- role: admin
permission:
columns:
- branch_id
- max_percent_per_order
- allow_combine_km1
- allow_refund
- refund_fee_percent
- refund_deadline_days
- refund_extend_days
- disabled
check: {}
update_permissions:
- role: admin
permission:
columns:
- max_percent_per_order
- allow_combine_km1
- allow_refund
- refund_fee_percent
- refund_deadline_days
- refund_extend_days
- disabled
filter:
deleted_at: { _is_null: true }5.2 Metadata Hasura — Cron Trigger
File: controller/metadata/cron_triggers/wallet_km2_expire_lots.yaml
yaml
name: wallet_km2_expire_lots
webhook: '{{WALLET_API_URL}}/schedulers/expire-km2-lots'
schedule: '5 0 * * *'
include_in_metadata: true
retry_conf:
num_retries: 3
timeout_seconds: 60
retry_interval_seconds: 10
comment: 'Đánh dấu lot Ví KM 2 hết hạn mỗi ngày 00:05 Asia/Ho_Chi_Minh'5.3 Metadata Hasura — Action: get_customer_km2_lots
File: controller/metadata/actions/get_customer_km2_lots.yaml
yaml
type: query
name: get_customer_km2_lots
definition:
kind: synchronous
handler: '{{WALLET_API_URL}}/actions'
forward_client_headers: true
request_transform:
body:
action: get_customer_km2_lots
permissions:
- role: user # NV / Admin tại POS / CRM (web)
- role: admin
- role: customer # Khách trên Flutter app (SCR-09) — chỉ xem lot của chính mình
comment: 'Đọc lot Ví KM 2 sau khi kiểm tra quyền truy cập khách/branch (hoặc auth.uid khi role=customer)'Kiểu đầu vào/đầu ra:
yaml
# input
type: GetCustomerKM2LotsInput
fields:
- name: customer_id
type: uuid!
- name: statuses
type: '[String!]'
# output
type: GetCustomerKM2LotsOutput
fields:
- name: lots
type: '[KM2Lot!]!'
- name: total_balance
type: bigint!Security rule:
- Role
user/admin(Diva Admin / POS): handler kiểm tra quyềncustomer_management:access+portal+branch_mode(Dynamic Permission v2). Handler được đọc lot cross-branch sau khi customer access pass để phục vụ DEC-014. - Role
customer(Flutter app — SCR-09): handler bắt buộc kiểm trainput.customer_id == X-Hasura-User-Id(auth context của Flutter app). Reject 403 nếu mismatch — chống khách query lot của khách khác qua app. - Không trả dữ liệu cho user không có quyền xem khách đó.
- Cùng rule áp dụng cho action
get_customer_km2_balance(DEC-029) ở section 5.3.1.
5.3.1 Metadata Hasura — Action: get_customer_km2_balance (DEC-029)
File: controller/metadata/actions/get_customer_km2_balance.yaml
yaml
type: query
name: get_customer_km2_balance
definition:
kind: synchronous
handler: '{{WALLET_API_URL}}/actions'
forward_client_headers: true
request_transform:
body:
action: get_customer_km2_balance
permissions:
- role: user # NV / Admin (web)
- role: admin
- role: customer # Khách trên Flutter app (SCR-09) — chỉ xem balance của chính mình
comment: 'Trả về số dư Ví KM 2 realtime từ active lot có expired_at > NOW() (DEC-029)'Kiểu đầu vào/đầu ra:
yaml
# input
type: GetCustomerKM2BalanceInput
fields:
- name: customer_id
type: uuid!
# output
type: GetCustomerKM2BalanceOutput
fields:
- name: available_amount
type: bigint! # SUM(lot.balance) WHERE status='active' AND expired_at > NOW()
- name: total_lots_active
type: Int!
- name: nearest_expiry_at
type: timestamptz # null nếu không có lot active; dùng cho banner cảnh báo SCR-06
- name: expired_unswept_amount
type: bigint! # SUM(balance) WHERE expired_at <= NOW() AND status='active' (scheduler chưa kịp clean) — hiển thị cho adminHandler logic (Go pseudocode):
go
// POST /actions, action="get_customer_km2_balance"
func (h *Handler) GetCustomerKM2Balance(c *gin.Context) {
// 1. Resolve effective permission qua Dynamic Permission v2 + branch_mode
perm := resolveDynamicPermission(ctx, "customer_management:access")
if !perm.Allowed {
return error403("no_permission_customer")
}
// 2. Verify customer trong scope user hiện tại
if !inCustomerScope(ctx, customerID, perm.BranchMode) {
return error403("customer_out_of_scope")
}
// 3. Query realtime (KHÔNG đọc wallet.amount)
row := h.db.QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN expired_at > NOW() THEN balance ELSE 0 END), 0) AS available_amount,
COALESCE(COUNT(*) FILTER (WHERE expired_at > NOW()), 0) AS total_lots_active,
MIN(CASE WHEN expired_at > NOW() THEN expired_at END) AS nearest_expiry_at,
COALESCE(SUM(CASE WHEN expired_at <= NOW() THEN balance ELSE 0 END), 0) AS expired_unswept_amount
FROM wallet_km2_lot
WHERE user_id = $1
AND status = 'active'
AND deleted_at IS NULL
`, customerID)
// 4. Trả về output
}Security rule: giống get_customer_km2_lots — handler resolve Dynamic Permission v2 + branch_mode trước khi query; không cấp direct SELECT trên wallet_km2_lot cho role user qua Hasura.
Performance: với idx_km2_lot_fifo partial (user_id, activated_at) WHERE status='active', query này chạy < 5ms với khách có < 100 lots (case thực tế max ~10 lots/khách).
Cache strategy: FE có thể cache response trong 30s ở payment screen (SCR-04); profile screen (SCR-06) phải refetch khi mở popup. KHÔNG cache lâu hơn 60s vì có thể lệch với cron expiry (00:05 daily).
5.4 Metadata Hasura — Action: deduct_km2_payment
File: controller/metadata/actions/deduct_km2_payment.yaml
yaml
type: mutation
name: deduct_km2_payment
definition:
kind: synchronous
handler: '{{WALLET_API_URL}}/actions'
forward_client_headers: true
request_transform:
body:
action: deduct_km2_payment
permissions:
- role: user
- role: admin
comment: 'Trừ Ví KM 2 theo FIFO, có idempotency và expiry guard'Kiểu đầu vào/đầu ra:
yaml
type: DeductKM2PaymentInput
fields:
- name: user_id
type: uuid!
- name: order_id
type: uuid!
- name: amount
type: bigint!
- name: payment_attempt_id
type: String!
# output
type: DeductKM2PaymentOutput
fields:
- name: payment_attempt_record_id
type: uuid!
- name: deducted_amount
type: bigint!
- name: deductions
type: '[KM2Deduction!]!'
- name: idempotent_replay
type: Boolean!Idempotency contract (DEC-028): nếu retry cùng payment_attempt_id, handler verify cross-check (order_id, customer_id, user_id) khớp record cũ rồi trả lại DeductKM2PaymentOutput đã lưu trong wallet_km2_payment_attempt.result với idempotent_replay=true; không lock/trừ lot lần nữa. Nếu cross-check fail → reject attempt_mismatch.
5.4.1 Idempotency Contract chi tiết (BẮT BUỘC trước Phase 1)
Bộ này khoá dedup ở mức field-level + behavior-level để TG-003 có evidence pass.
A. Cách sinh payment_attempt_id:
| Khía cạnh | Quy ước |
|---|---|
| Format | UUID v4, gen client-side khi NV bấm chip Ví KM 2 (không phải khi bấm Thanh toán) |
| Scope | 1 attempt = 1 lần NV nhấn chip Ví KM 2 cho 1 order; nếu NV bỏ chọn rồi chọn lại → gen UUID mới |
| Persist | Lưu trong store FE orderPaymentStore.km2AttemptId[order_id]; reset khi đóng tab order hoặc submit thành công |
| Truyền BE | Gửi trong payload deduct_km2_payment mỗi lần submit (kể cả retry) |
| Bypass FE | Backend KHÔNG tin attempt_id từ FE; phải verify cùng order_id + customer_id + portal + user_id |
B. Dedup window + bảng wallet_km2_payment_attempt (schema chốt — xem C4 cho CREATE TABLE đầy đủ):
| Field | Type | Mục đích |
|---|---|---|
id | UUID PK | Technical PK (Diva pattern); KHÔNG dùng làm idempotency key |
payment_attempt_id | UUID NOT NULL, UNIQUE | Key dedup chính (client gen UUID v4) |
order_id | UUID NOT NULL | Composite check khi verify duplicate |
user_id | UUID NOT NULL | NV submit (cross-check verify chống bypass FE) |
customer_id | UUID NOT NULL | Khách bị trừ (cross-check verify) |
requested_amount | INT8 NOT NULL | Số tiền caller yêu cầu trừ |
deducted_amount | INT8 NOT NULL DEFAULT 0 | Số tiền BE thực sự trừ (≤ requested khi gặp lot expired race) |
status | TEXT NOT NULL, enum processing/completed/failed/expired | Lifecycle |
result | JSONB | Kết quả DeductKM2PaymentOutput của lần đầu thành công (replay khi duplicate) |
error | JSONB NULL | Lỗi của lần đầu fail (nếu retry không idempotent) |
request_hash | TEXT NULL | Detect retry cùng key nhưng khác payload |
lock_holder | UUID NULL | UUID worker đang xử lý (chống concurrent retry) |
lock_expires_at | TIMESTAMPTZ NULL | Lock TTL (90s từ requested_at) |
requested_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Mốc tính TTL dedup window (7 ngày) |
created_at, updated_at, deleted_at, created_by, updated_by | Diva audit fields | Soft delete + audit trail |
- TTL dedup: record giữ trong 7 ngày kể từ
requested_at(đủ cho retry sau khi BE 5xx và Sale POS thử lại); cronnightlyxoá recordstatus='completed'quá 7 ngày. - Retry behavior:
- Duplicate khi
status='completed': trảresultcached +idempotent_replay=truengay (không lock lot, không trừ lần 2) - Duplicate khi
status='processing'+ lock chưa expire: trả409 Conflict"Yêu cầu đang xử lý" - Duplicate khi
status='processing'+ lock expired (timeout > 90s): coi như crash; tự rollback record vềstatus='failed', cho retry mới - Duplicate khi
status='failed': cho retry; xoá record cũ insert mới - Duplicate khi
status='expired'(> 7 ngày): coi như attempt mới
- Duplicate khi
C. Distributed lock chống concurrent retry:
BEGIN;
INSERT INTO wallet_km2_payment_attempt (payment_attempt_id, order_id, customer_id, status, lock_holder, lock_expires_at)
VALUES ($1, $2, $3, 'processing', $worker_id, NOW() + INTERVAL '90 seconds')
ON CONFLICT (payment_attempt_id) DO UPDATE
SET lock_holder = EXCLUDED.lock_holder,
lock_expires_at = EXCLUDED.lock_expires_at,
status = 'processing'
WHERE wallet_km2_payment_attempt.status = 'failed'
OR wallet_km2_payment_attempt.lock_expires_at < NOW();
-- Nếu UPDATE 0 rows → record đã processing/completed → return cached result hoặc 409
COMMIT;D. Cross-check verify (chống bypass FE):
Backend nhận payment_attempt_id PHẢI verify:
order_idpayload khớporder_idrecord đầu tiên (nếu duplicate)customer_idpayload khớpuser_idJWT khớpuser_idrecord (cùng NV mới được retry; NV khác → reject 409)
→ Bypass scenario "đổi order_id nhưng giữ attempt_id" sẽ bị reject attempt_mismatch.
E. Test scenarios bắt buộc cho TG-003 evidence pass:
| Test ID | Scenario | Expected |
|---|---|---|
TC-IDEMPOTENT-01 | Bấm Thanh toán 1 lần, BE 200 | Lot trừ 1 lần; attempt.status='completed'; idempotent_replay=false |
TC-IDEMPOTENT-02 | Bấm 2 lần liên tục < 1s (double-click) | Lot trừ 1 lần; lần 2 nhận idempotent_replay=true (FE đợi response lần đầu thì tự bỏ) hoặc 409 |
TC-IDEMPOTENT-03 | Bấm 1 lần, mạng timeout, retry sau 5s | Lot trừ 1 lần; idempotent_replay=true |
TC-IDEMPOTENT-04 | Crash giữa lúc lock; sau 90s NV retry | Lock expired → cho retry mới; lot trừ 1 lần (vì transaction lần đầu rollback do crash) |
TC-IDEMPOTENT-05 | NV A submit; đồng thời NV B submit cùng attempt_id (giả định bypass FE) | A thắng; B nhận 409 attempt_mismatch |
TC-IDEMPOTENT-06 | NV submit khi 1 lot vừa hết hạn (race với cron 00:05) | Tx check expired_at > NOW() → skip lot, FIFO chuyển; idempotent_replay=false cho retry sau |
TC-IDEMPOTENT-07 | Submit thành công; sau 8 ngày retry cùng attempt_id | Record đã expired/cleanup → coi như attempt mới; trừ lần 2 (vì user thực sự muốn payment thứ 2) |
Acceptance: TL ký xác nhận thiết kế trên + BE ghi unit test cho 7 test ID này trước khi merge
deduct_km2_paymentlên staging. Đây là gate TL-001.
5.5 Metadata Hasura — Mutation/Action: refund_km2_wallet
refund_km2_wallet là behavior/handler cho Yêu cầu hoàn tiền hiện có. FE tạo transaction_request với behavior_id = 'refund_km2_wallet'; action/handler xử lý khi request được duyệt qua changeStatusTransaction, tương tự các nhánh refund_order, refund_order_cosmetic, refund_topup hiện tại.
File gợi ý: controller/metadata/actions/refund_km2_wallet.yaml nếu tách action tạo request; hoặc extend mutation tạo transaction_request hiện có nếu team giữ pattern insert trực tiếp từ FE.
yaml
type: mutation
name: refund_km2_wallet
definition:
kind: synchronous
handler: '{{WALLET_API_URL}}/actions'
forward_client_headers: true
request_transform:
body:
action: refund_km2_wallet
permissions:
- role: user
- role: admin
comment: 'Hoàn ví KM2 — tạo/xử lý yêu cầu hoàn tiền sau khi backend kiểm tra Dynamic Permission'Permission note: Hasura role
user/adminchỉ là technical role. Handler phải tự resolve effective permission theorefund_request_management_submenu:access/create/update/approve/payment,portal,branch_mode, reviewer config và quyền xem customer/lot. Không hard-code rolemanager/admin.
Kiểu đầu vào/đầu ra:
yaml
# input
type: RefundKM2WalletInput
fields:
- name: lot_id
type: uuid!
- name: request_id
type: uuid
description: transaction_request.id khi xử lý sau duyệt
- name: km2_deduct_amount
type: bigint!
- name: refund_method
type: String! # cash / wallet_vnd / bank_transfer
- name: refund_amount
type: bigint!
- name: note
type: String
# output
type: RefundKM2WalletOutput
fields:
- name: success
type: Boolean!
- name: request_id
type: uuid
- name: refund_amount
type: bigint! # số tiền khách nhận
- name: message
type: StringLuồng tạo request:
- FE mở lot trong customer profile qua
get_customer_km2_lots. - User bấm
Hoàn ví KM2; FE tạotransaction_request:behavior_id = 'refund_km2_wallet'type = 'W'status = 'R'reference_id = wallet_km2_lot.idcustomer_id,branch_id,payment_method_id,reference_amount = refund_amounttransaction_infohoặcrefund_request_infolưu snapshot:lot_id,km2_deduct_amount,refund_amount,refund_method,refund_ratio,refund_fee.
- Request xuất hiện trong màn Yêu cầu hoàn tiền hiện có với label "Hoàn ví KM2".
- Khi approver duyệt qua
changeStatusTransaction, backend gọi handlerrefund_km2_walletđể lock lot, trừ KM2 balance, ghi deduction typerefund_km2_wallet, và hoàn tiền thật theorefund_method.
5.6 Metadata Hasura — Hàm (Phase 3)
File: controller/metadata/databases/wallet/functions/public_get_km2_report_summary.yaml
yaml
function:
name: get_km2_report_summary
schema: public
configuration:
session_argument: hasura_session5.7 Query GraphQL
File: src/modules/settings/graphql/wallet-km2-config.graphql
graphql
query GetWalletKM2Config {
wallet_km2_config(
where: {
branch_id: { _is_null: true }
deleted_at: { _is_null: true }
}
limit: 1
) {
id
branch_id
max_percent_per_order
allow_combine_km1
allow_refund
refund_fee_percent
refund_deadline_days
refund_extend_days
disabled
}
}
mutation UpsertWalletKM2Config(
$object: wallet_km2_config_insert_input!
$on_conflict: wallet_km2_config_on_conflict!
) {
insert_wallet_km2_config_one(
object: $object
on_conflict: $on_conflict
) {
id
}
}File: src/modules/user/graphql/wallet-km2-lots.graphql
graphql
query GetCustomerKM2Lots($input: GetCustomerKM2LotsInput!) {
get_customer_km2_lots(input: $input) {
total_balance
lots {
id
prepaid_card_id
order_id
package_name
package_price
wallet_value
initial_amount
used_amount
balance
status
activated_at
expired_at
deductions {
id
order_id
amount
type
created_at
}
}
}
}Security note: FE không query trực tiếp
wallet_km2_lot/wallet_km2_lot_deductionbằng roleuser; mọi dữ liệu lot trong customer profile đi qua actionget_customer_km2_lots.
File: src/modules/ecommerce/graphql/payment-km2.graphql
graphql
query GetKM2PaymentInfo($userId: uuid!) {
wallet_km2_config(
where: {
branch_id: { _is_null: true }
deleted_at: { _is_null: true }
disabled: { _eq: false }
}
limit: 1
) {
max_percent_per_order
allow_combine_km1
}
wallet(
where: {
user_id: { _eq: $userId }
wallet_type_id: { _eq: "VND_PROMOTION_2" }
deleted_at: { _is_null: true }
}
) {
amount
}
}5.8 Bảng mã lỗi
| Tình huống | Mã lỗi | Trạng thái | FE xử lý |
|---|---|---|---|
| Số dư KM2 không đủ | KM2_INSUFFICIENT_BALANCE | 200 (GraphQL error) | Toast lỗi, disable submit |
| Config disabled | KM2_DISABLED | 200 (GraphQL error) | Ẩn option Ví KM 2 |
| Max% vượt giới hạn | KM2_MAX_PERCENT_EXCEEDED | 200 (GraphQL error) | Re-calculate, hiển thị đúng số tiền |
| Lot không tồn tại | KM2_LOT_NOT_FOUND | 200 (GraphQL error) | Toast lỗi |
| Lot đã expired/refunded | KM2_LOT_INVALID_STATUS | 200 (GraphQL error) | Refresh danh sách lần mua Ví KM2 |
| Refund không được phép | KM2_REFUND_NOT_ALLOWED | 200 (GraphQL error) | Toast "Cấu hình không cho phép hoàn ví KM2" |
| Refund quá hạn | KM2_REFUND_DEADLINE_PASSED | 200 (GraphQL error) | Toast "Đã quá thời hạn hoàn ví KM2" |
| Race condition (lot locked) | Retry qua DB lock | Transparent | Request chờ lock, không cần FE xử lý |
| Không có item eligible | KM2_NO_ELIGIBLE_ITEMS | 200 (GraphQL error) | Ẩn option Ví KM 2, tooltip giải thích |
C6) Component frontend
Cấu trúc file
src/modules/settings/
├── pages/
│ └── PromotionWallet.tsx <- SỬA (thêm section Config Ví KM 2)
├── components/
│ ├── wallet-km2/
│ │ └── SettingsKM2Config.tsx <- MỚI
│ └── prepaid-card/
│ └── PrepaidCardForm.tsx <- SỬA (thêm wallet_target, expiry_months)
├── graphql/
│ └── wallet-km2-config.graphql <- MỚI
src/modules/ecommerce/
├── components/
│ ├── order/
│ │ ├── NormalPayment.tsx <- SỬA (thêm payment method wallet_promotion_2)
│ │ └── OrderAddMultipleInvoiceDialog.tsx <- SỬA (max% logic, toggle KM1+KM2)
│ └── prepaid-order/
│ └── PrepaidOrderCreate.tsx <- SỬA (conditional cột "NẠP VÍ KM 2":
│ v-if có ≥1 item wallet_target=KM2.
│ Sidebar thêm dòng "Tiền vào ví KM 2"
│ khi tổng > 0. Dòng KM2: DIVA=0, KM=0)
├── graphql/
│ └── payment-km2.graphql <- MỚI
src/modules/user/
├── components/
│ └── customer/
│ ├── StatisticCustomerWallets.tsx <- SỬA (thêm card Ví KM 2)
│ └── StatisticWalletKM2Tab.tsx <- MỚI (tab chi tiết danh sách lần mua Ví KM2)
├── graphql/
│ └── wallet-km2-lots.graphql <- MỚI
src/modules/report/ (Phase 3)
├── pages/
│ └── ReportKM2Dashboard.tsx <- MỚI
├── components/
│ └── km2/
│ ├── KM2SummaryCards.tsx <- MỚI
│ ├── KM2ByWalletPackageTypeTable.tsx <- MỚI
│ ├── KM2ExpiringTable.tsx <- MỚI
│ └── KM2TopCustomersTable.tsx <- MỚI
├── graphql/
│ └── km2-report.graphql <- MỚILogic chính (pseudocode)
SettingsKM2Config.tsx
typescript
// Query config, upsert khi lưu
interface KM2Config {
max_percent_per_order: number // 1-100
allow_combine_km1: boolean
allow_refund: boolean
refund_fee_percent: number // 0-100
refund_deadline_days: number | null
refund_extend_days: number
disabled: boolean
}
// Validation rules
const rules = {
max_percent_per_order: [required, between(1, 100), integer],
refund_fee_percent: [between(0, 100), integer],
refund_deadline_days: [nullable, positiveInteger],
refund_extend_days: [required, positiveInteger],
}PrepaidCardForm.tsx (thêm fields)
typescript
// Thêm vào form hiện tại
interface PrepaidCardFormExtension {
wallet_target: 'VND' | 'VND_PROMOTION_2' // radio, mặc định 'VND'
expiry_months: number | null // hiện khi wallet_target = 'VND_PROMOTION_2'
}
// Hiển thị có điều kiện:
// wallet_target === 'VND_PROMOTION_2' -> hiện expiry_months field (bắt buộc, > 0)
// wallet_target === 'VND' -> ẩn expiry_monthsNormalPayment.tsx + OrderAddMultipleInvoiceDialog.tsx
typescript
// Pseudocode cho lựa chọn phương thức thanh toán
function computeKM2Payment(orderItems: OrderItem[], config: KM2Config, km2Balance: number): number {
const eligibleTotal = orderItems
.filter(item => item.product.allow_promo_wallet_2)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
if (eligibleTotal === 0 || km2Balance === 0) return 0
const km2Max = Math.floor(eligibleTotal * config.max_percent_per_order / 100)
return Math.min(km2Max, km2Balance)
}
// Logic toggle KM1+KM2
function isKM2Visible(config: KM2Config, selectedPayments: string[]): boolean {
if (config.disabled) return false
if (!config.allow_combine_km1 && selectedPayments.includes('wallet_promotion')) return false
return true
}
function isKM1Visible(config: KM2Config, selectedPayments: string[]): boolean {
if (!config.allow_combine_km1 && selectedPayments.includes('wallet_promotion_2')) return false
return true
}StatisticWalletKM2Tab.tsx
typescript
// Danh sách lot kèm cảnh báo hết hạn
interface LotDisplay {
id: string
packageName: string
initialAmount: number
usedAmount: number
balance: number
status: 'active' | 'exhausted' | 'expired' | 'refunded'
activatedAt: string
expiredAt: string
}
// Logic cảnh báo
function getExpiryWarning(lot: LotDisplay): { show: boolean; urgent: boolean; text: string } {
if (lot.status !== 'active' || lot.balance === 0) return { show: false, urgent: false, text: '' }
const daysLeft = differenceInDays(new Date(lot.expiredAt), new Date())
if (daysLeft > 30) return { show: false, urgent: false, text: '' }
return {
show: true,
urgent: daysLeft <= 7,
text: `Gói Ví KM2 ${lot.packageName} còn ${formatCurrency(lot.balance)} sẽ hết hạn ngày ${formatDate(lot.expiredAt)}`
}
}C7) Chiến lược migration
Migrations
| DB | Latest existing | New migration(s) |
|---|---|---|
| wallet | 1770439000000 | 1770439100000_create_wallet_km2_tables |
| ecommerce | 1773200000000 | 1773200100000_alter_prepaid_card_product_service_km2 |
Migration 1: wallet/1770439100000_create_wallet_km2_tables/up.sql
sql
-- 1. Seed data: wallet_type + payment_gateway
INSERT INTO wallet_type (id, name, currency_code, promotion, "default", disabled)
VALUES ('VND_PROMOTION_2', 'Ví KM 2', 'VND', true, false, false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO payment_gateway (id, name, icon_url, web_visible, app_visible)
VALUES ('wallet_promotion_2', 'Ví KM 2', 'wallet_promotion_2.png', true, true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO payment_gateway_wallet_type (payment_gateway_id, wallet_type_id)
VALUES ('wallet_promotion_2', 'VND_PROMOTION_2')
ON CONFLICT DO NOTHING;
-- 2. Table: wallet_km2_lot
CREATE TABLE wallet_km2_lot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
branch_id UUID NOT NULL,
prepaid_card_id UUID NOT NULL,
order_id UUID NOT NULL,
package_name TEXT NOT NULL,
package_price INT8 NOT NULL,
wallet_value INT8 NOT NULL,
initial_amount INT8 NOT NULL,
credited_amount INT8 NOT NULL DEFAULT 0,
used_amount INT8 NOT NULL DEFAULT 0,
balance INT8 NOT NULL DEFAULT 0,
CONSTRAINT chk_lot_balance CHECK (balance = credited_amount - used_amount),
CONSTRAINT chk_lot_balance_non_negative CHECK (balance >= 0),
CONSTRAINT chk_credited_le_initial CHECK (credited_amount <= initial_amount),
status TEXT NOT NULL DEFAULT 'active',
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expired_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
CREATE INDEX idx_wallet_km2_lot_user_status
ON wallet_km2_lot (user_id, status, activated_at ASC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_wallet_km2_lot_expiry
ON wallet_km2_lot (status, expired_at)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX idx_wallet_km2_lot_branch
ON wallet_km2_lot (branch_id)
WHERE deleted_at IS NULL;
-- 3. Table: wallet_km2_payment_attempt (DEC-028 — schema chốt merge)
CREATE TABLE wallet_km2_payment_attempt (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_attempt_id UUID NOT NULL,
order_id UUID NOT NULL,
user_id UUID NOT NULL,
customer_id UUID NOT NULL,
requested_amount INT8 NOT NULL,
deducted_amount INT8 NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'processing',
result JSONB,
error JSONB,
request_hash TEXT,
lock_holder UUID,
lock_expires_at TIMESTAMPTZ,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
CREATE UNIQUE INDEX uq_km2_payment_attempt_id
ON wallet_km2_payment_attempt (payment_attempt_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_km2_payment_attempt_order
ON wallet_km2_payment_attempt (order_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_km2_payment_attempt_lock
ON wallet_km2_payment_attempt (lock_expires_at)
WHERE deleted_at IS NULL AND status = 'processing';
CREATE INDEX idx_km2_payment_attempt_ttl
ON wallet_km2_payment_attempt (requested_at)
WHERE deleted_at IS NULL AND status IN ('completed', 'failed');
-- 4. Table: wallet_km2_lot_deduction
CREATE TABLE wallet_km2_lot_deduction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lot_id UUID NOT NULL,
order_id UUID NOT NULL,
payment_attempt_record_id UUID,
payment_attempt_id TEXT,
transaction_id UUID,
amount INT8 NOT NULL,
type TEXT NOT NULL DEFAULT 'deduction',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
deleted_at TIMESTAMPTZ,
CONSTRAINT fk_lot_deduction_lot
FOREIGN KEY (lot_id) REFERENCES wallet_km2_lot(id),
CONSTRAINT fk_lot_deduction_attempt
FOREIGN KEY (payment_attempt_record_id) REFERENCES wallet_km2_payment_attempt(id)
);
CREATE INDEX idx_lot_deduction_lot
ON wallet_km2_lot_deduction (lot_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_lot_deduction_order
ON wallet_km2_lot_deduction (order_id)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_lot_deduction_payment_attempt_lot
ON wallet_km2_lot_deduction (payment_attempt_record_id, lot_id, type)
WHERE deleted_at IS NULL AND payment_attempt_record_id IS NOT NULL AND type = 'deduction';
-- 5. Table: wallet_km2_config
CREATE TABLE wallet_km2_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID, -- Day-1: giữ NULL, reserve cho branch override phase sau
max_percent_per_order INT NOT NULL DEFAULT 20,
allow_combine_km1 BOOLEAN NOT NULL DEFAULT FALSE,
allow_refund BOOLEAN NOT NULL DEFAULT TRUE,
refund_fee_percent INT NOT NULL DEFAULT 20,
refund_deadline_days INT DEFAULT 30,
refund_extend_days INT NOT NULL DEFAULT 30,
disabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by UUID,
updated_by UUID
);
CREATE UNIQUE INDEX uq_wallet_km2_config_global_active
ON wallet_km2_config ((branch_id IS NULL))
WHERE branch_id IS NULL AND disabled = FALSE AND deleted_at IS NULL;
CREATE UNIQUE INDEX uq_wallet_km2_config_global_row
ON wallet_km2_config ((branch_id IS NULL))
WHERE branch_id IS NULL AND deleted_at IS NULL;
-- 6. Default config (seed tắt; PO/Admin bật ở go-live)
INSERT INTO wallet_km2_config (branch_id, max_percent_per_order, allow_combine_km1, allow_refund, refund_fee_percent, refund_deadline_days, refund_extend_days, disabled)
VALUES (NULL, 20, FALSE, TRUE, 20, 30, 30, TRUE);Operational note: v1 chỉ vận hành 1 config global. Migration seed ở trạng thái tắt (
disabled=true) để tránh KM2 xuất hiện trước readiness; PO/Admin bậtdisabled=falsetrong go-live. Không seed nhiều dòng theo branch ở Day-1 dù schema còn chừa khả năng mở rộng.
Migration 1 down: wallet/1770439100000_create_wallet_km2_tables/down.sql
sql
DROP TABLE IF EXISTS wallet_km2_lot_deduction;
DROP TABLE IF EXISTS wallet_km2_payment_attempt;
DROP TABLE IF EXISTS wallet_km2_config;
DROP TABLE IF EXISTS wallet_km2_lot;
DELETE FROM payment_gateway_wallet_type WHERE wallet_type_id = 'VND_PROMOTION_2';
DELETE FROM payment_gateway WHERE id = 'wallet_promotion_2';
DELETE FROM wallet_type WHERE id = 'VND_PROMOTION_2';Migration 2: ecommerce/1773200100000_alter_prepaid_card_product_service_km2/up.sql
sql
-- 1. prepaid_card
ALTER TABLE prepaid_card
ADD COLUMN wallet_target TEXT NOT NULL DEFAULT 'VND',
ADD COLUMN expiry_months INT;
COMMENT ON COLUMN prepaid_card.wallet_target IS 'VND (flow cũ) / VND_PROMOTION_2 (Ví KM 2)';
COMMENT ON COLUMN prepaid_card.expiry_months IS 'Số tháng. NULL = không hạn. Áp dụng khi wallet_target = VND_PROMOTION_2';
-- 2. product + service
ALTER TABLE product ADD COLUMN allow_promo_wallet_2 BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE service ADD COLUMN allow_promo_wallet_2 BOOLEAN NOT NULL DEFAULT FALSE;Migration 2 down: ecommerce/1773200100000_alter_prepaid_card_product_service_km2/down.sql
sql
ALTER TABLE prepaid_card DROP COLUMN IF EXISTS wallet_target;
ALTER TABLE prepaid_card DROP COLUMN IF EXISTS expiry_months;
ALTER TABLE product DROP COLUMN IF EXISTS allow_promo_wallet_2;
ALTER TABLE service DROP COLUMN IF EXISTS allow_promo_wallet_2;Migration 3: ecommerce/1773300100000_seed_payment_method_and_affiliate_config_km2/up.sql
Ref: DEC-030 — KM 2 thanh toán bằng ví KHÔNG sinh commission cho CTV. Mục tiêu: Tạo
payment_methodrow mớiwallet_promotion_2+ seedaffiliate_config3 row default FALSE. Idempotent: dùngON CONFLICT DO NOTHING— chạy lại không lỗi.
sql
-- 3.1) Đăng ký payment_method mới (KM2 wallet)
INSERT INTO payment_method (id, description, app_visible, web_visible, has_balance, image, disabled)
VALUES (
'wallet_promotion_2',
'{"en":"Wallet Promotion 2","vi":"Ví khuyến mãi 2"}'::jsonb,
TRUE, -- app_visible: hiển thị trên customer Flutter app (SCR-09)
TRUE, -- web_visible: hiển thị trên POS/Admin (SCR-04 chip)
TRUE, -- has_balance: track theo lot
NULL,
TRUE -- disabled: mặc định tắt; bật qua SCR-01 ở go-live (DEC-023)
)
ON CONFLICT (id) DO NOTHING;
-- 3.2) Seed affiliate_config: KM2 KHÔNG sinh commission cho CTV (DEC-030)
INSERT INTO affiliate_config (order_kind, payment_method_id, enable) VALUES
('service', 'wallet_promotion_2', FALSE),
('cosmetic', 'wallet_promotion_2', FALSE),
('prepaid', 'wallet_promotion_2', FALSE)
ON CONFLICT (order_kind, payment_method_id) DO NOTHING;Migration 3 down: ecommerce/1773300100000_seed_payment_method_and_affiliate_config_km2/down.sql
sql
DELETE FROM affiliate_config WHERE payment_method_id = 'wallet_promotion_2';
DELETE FROM payment_method WHERE id = 'wallet_promotion_2';Migration 4: notification/1773400100000_update_vnd_wallet_balance_template_km2/up.sql
Ref: SCR-FR012-SMS-01 — Update template SMS
vnd_wallet_balanceđể bao balance KM 2. Pattern: 4 lớp protection theo CLAUDE.md Pitfalls Map (filter chặt + pre-check anchor + idempotent guard + post-assert). Lưu ý anchor: CầnEVIDENCE_PACKghi rõ template hiện tại có chứa chuỗi'KM con lai: {{.wallet_promotion_amount}}'(anchor cụ thể) trước khi chạy migration.
sql
DO $$
DECLARE
target_missing INT;
missing_anchor INT;
affected INT;
anchor_text TEXT := 'KM con lai: {{.wallet_promotion_amount}}';
replacement TEXT := 'KM con lai: {{.wallet_promotion_amount}}, KM2 con lai: {{.wallet_promotion_2_amount}}';
BEGIN
-- 1) Đếm row cần update — filter chặt theo runtime usage
SELECT COUNT(*) INTO target_missing
FROM notification_template
WHERE name = 'vnd_wallet_balance'
AND disabled = false
AND content NOT LIKE '%{{.wallet_promotion_2_amount}}%';
-- 2) Idempotent: lần 2 → target_missing = 0 → return
IF target_missing = 0 THEN
RAISE NOTICE 'Migration 4: already applied (target_missing=0)';
RETURN;
END IF;
-- 3) PRE-CHECK ANCHOR — REPLACE silent no-op nếu anchor missing
SELECT COUNT(*) INTO missing_anchor
FROM notification_template
WHERE name = 'vnd_wallet_balance'
AND disabled = false
AND content NOT LIKE '%{{.wallet_promotion_2_amount}}%'
AND content NOT LIKE '%' || anchor_text || '%';
IF missing_anchor > 0 THEN
RAISE EXCEPTION 'Migration 4: missing anchor "%" in % rows', anchor_text, missing_anchor;
END IF;
-- 4) UPDATE — thêm content LIKE anchor để chỉ touch row có anchor
UPDATE notification_template
SET content = REPLACE(content, anchor_text, replacement)
WHERE name = 'vnd_wallet_balance'
AND disabled = false
AND content NOT LIKE '%{{.wallet_promotion_2_amount}}%'
AND content LIKE '%' || anchor_text || '%';
GET DIAGNOSTICS affected = ROW_COUNT;
-- 5) Defense-in-depth: affected == target_missing
IF affected <> target_missing THEN
RAISE EXCEPTION 'Migration 4: expected %, got %', target_missing, affected;
END IF;
RAISE NOTICE 'Migration 4: updated % template rows', affected;
END $$;
-- Đăng ký variable mới để FE/BE biết placeholder
INSERT INTO notification_variable (id, name, description, type, data, disabled) VALUES (
'{{.wallet_promotion_2_amount}}',
'Số dư Ví KM 2 sau giao dịch',
'Số dư còn lại trong Ví KM 2 sau khi invoice complete',
'wallet_variable',
'4.700.000đ',
FALSE
)
ON CONFLICT (id) DO NOTHING;Migration 4 down: notification/1773400100000_update_vnd_wallet_balance_template_km2/down.sql
sql
DO $$
DECLARE
affected INT;
BEGIN
UPDATE notification_template
SET content = REPLACE(
content,
'KM con lai: {{.wallet_promotion_amount}}, KM2 con lai: {{.wallet_promotion_2_amount}}',
'KM con lai: {{.wallet_promotion_amount}}'
)
WHERE name = 'vnd_wallet_balance'
AND disabled = false
AND content LIKE '%{{.wallet_promotion_2_amount}}%';
GET DIAGNOSTICS affected = ROW_COUNT;
RAISE NOTICE 'Migration 4 down: reverted % rows', affected;
END $$;
DELETE FROM notification_variable WHERE id = '{{.wallet_promotion_2_amount}}';Thứ tự triển khai
1. DB Migration (wallet) — tables, indexes, seed data
2. DB Migration (ecommerce M2) — ALTER columns prepaid_card/product/service
3. DB Migration (ecommerce M3) — payment_method + affiliate_config seed (DEC-030)
4. DB Migration (notification M4) — update vnd_wallet_balance template (SCR-FR012-SMS-01)
5. Hasura Metadata — table tracking, permissions, relationships, cron trigger, action
6. BE Deploy (wallet-api) — transaction_insert rẽ nhánh, scheduler handler, refund_km2_wallet handler, action get_customer_km2_balance + get_customer_km2_lots
7. BE Deploy (ecommerce-api) — payment_order, order_confirm, invoice, delivery updates
8. BE Deploy (notification-api) — render placeholder {{.wallet_promotion_2_amount}}
9. FE Codegen — graphql codegen
10. FE Deploy (admin/POS) — Settings config, payment UI, customer profile tab
11. FE Deploy (Flutter customer) — SCR-09 thêm tab thứ 3 "Ví khuyến mãi 2" vào `wallet_screen.dart` (DEC-031 — dev tự chốt 1 trong 3 phương án widget: refactor `FolderTabs` / swap `CustomTabbar` / swap `TabPageWidget`) + home widget card thứ 5 (build mới, đẩy app store)
12. FE Deploy (Flutter staff) — SCR-10 customer detail balance KM 2 (`customer_repository.impl.dart:473-481` thêm call + UI row mới)Lưu ý 1: Step 1-5 có thể chạy trước và độc lập. Step 6-8 cần Step 1-5 xong. Step 9-12 cần Step 6-8 xong. Lưu ý 2: Step 11-12 (Flutter app customer + staff) có chu kỳ release riêng (App Store / Play Store review 1-3 ngày). Cần build mobile sớm để timing khớp Phase 1 go-live (xem
handoff.mdtimeline). Lưu ý 3: Step 11-12 chia sẻ chung sharedWalletTypeenum + l10n key (core/) — phải release đồng bộ để 2 app build cùng versioncorepackage.
C8) Bảo mật
Dynamic Permission v2 application matrix
Không hard-code quyền theo role name. Staff/Sale, Manager, Admin chỉ là default seed tham khảo; runtime phải tính:
effective_permission = role_module.actions + portal + branch_mode + backend enforcement
| Use case | Module | Portal | Action | Default seed | Backend enforcement |
|---|---|---|---|---|---|
| Bật/tắt KM2, sửa max%, combine KM1+KM2, refund policy | internal_configuration | admin | update | Admin / owner cấu hình hệ thống | Verify action trước mutation config/payment method; audit updated_by |
Tạo/sửa loại Gói Ví KM2 (prepaid_card) | internal_configuration | admin | update | Admin / owner cấu hình hệ thống | Verify action trước insert/update prepaid_card.wallet_target=VND_PROMOTION_2 |
Sửa flag allow_promo_wallet_2 trên SP/DV | internal_configuration | admin | update | Admin / owner cấu hình hệ thống | POS không sửa; backend/mutation check action nếu đi qua action |
| Bán Gói Ví KM2 | prepaid_order | pos | create | POS/Sale/Manager theo portal POS | Check branch/session scope khi tạo order |
| Thu tiền đơn bán Gói Ví KM2 | prepaid_order | pos | payment | POS/Sale/Manager theo portal POS | Không cho installment; check payment method visibility |
| Thanh toán đơn dịch vụ bằng KM2 | service_order | pos | payment | POS/Sale/Manager theo portal POS | deduct_km2_payment verify order/customer/branch scope, idempotency và expiry guard |
| Thanh toán đơn mỹ phẩm/sản phẩm bằng KM2 | product_order | pos | payment | POS/Sale/Manager theo portal POS | deduct_km2_payment verify order/customer/branch scope |
| Xem tab KM2 trong profile khách | customer_management | admin,pos,crm | access | Theo quyền customer hiện có | get_customer_km2_lots trả least-data sau khi pass customer access; không direct select lot |
| Xem all-branch customer/KM2 | customer_management | admin,pos,crm | view_all | Admin / role được cấp toàn hệ thống | Vẫn phải xét branch_mode; không infer từ tên role |
| Xem Hoàn ví KM2 request | refund_request_management_submenu | admin,pos | access | Kế toán/Manager/Admin theo reviewer config | Cho list/read request behavior refund_km2_wallet nếu thuộc branch/scope |
| Tạo Hoàn ví KM2 request | refund_request_management_submenu | admin,pos | create | Người được tạo yêu cầu hoàn tiền | insert_transaction_request tạo behavior refund_km2_wallet nếu có quyền customer/lot |
| Sửa Hoàn ví KM2 request | refund_request_management_submenu | admin,pos | update | Kế toán/Manager/Admin theo reviewer config | Chỉnh thông tin/chứng từ request trước khi duyệt theo pattern hiện có |
| Duyệt/từ chối/hủy Hoàn ví KM2 | refund_request_management_submenu | admin,pos | approve | Reviewer hợp lệ theo cấu hình duyệt | changeStatusTransaction verify reviewer step + branch_mode + behavior refund_km2_wallet |
| Thực hiện hoàn tiền thật | refund_request_management_submenu | admin,pos | payment | Kế toán/Manager/Admin theo reviewer config | Chỉ sau approve; cash/wallet/bank transfer theo request, audit request log |
| Report KM2 Phase 3 | Route report theo PD-001 | admin | access | Manager/Admin khi Phase 3 bật | Manager branch scoped; Admin/all-branch chỉ khi view_all/branch_mode cho phép |
ModuleOperationMapping / auth mapping cần cập nhật
| Module/action | Operations / handlers cần map | Ghi chú |
|---|---|---|
internal_configuration:update | wallet_km2_config, payment_method, payment_gateway_wallet_type, prepaid_card, product/service KM2 flag mutations | Reuse semantics KM1 settings/prepaid config |
customer_management:access | get_customer_km2_lots, customer wallet summary read | Không expose direct lot select toàn bảng |
service_order:payment | deduct_km2_payment cho đơn dịch vụ | Handler verify order/payment scope |
product_order:payment | deduct_km2_payment cho đơn mỹ phẩm/sản phẩm | Handler verify order/payment scope |
prepaid_order:create | Create prepaid order có wallet_target=VND_PROMOTION_2 | Không cho installment |
prepaid_order:payment | Settlement/paid invoice cho Gói Ví KM2 | Tạo lot theo multi-payment rule |
refund_request_management_submenu:access/create/update/approve/payment | transaction_request behavior refund_km2_wallet, insert_transaction_request, changeStatusTransaction, handler refund_km2_wallet | Label UI "Hoàn ví KM2"; không dùng action legacy cũ |
Bảo mật theo dòng Hasura (Row-Level Security)
| Bảng | Role | Select | Insert | Update | Delete |
|---|---|---|---|---|---|
wallet_km2_lot | user (NV) | Không cấp direct select. Đọc qua action get_customer_km2_lots sau khi kiểm tra quyền khách/branch | Qua action only | Qua action only | Không |
wallet_km2_lot | admin | deleted_at IS NULL | Qua action only | Qua action only | Soft delete only |
wallet_km2_payment_attempt | user (NV) | Không cấp direct select. Kết quả retry trả qua action deduct_km2_payment | Qua action only | Qua action only | Không |
wallet_km2_payment_attempt | admin | deleted_at IS NULL | Qua action only | Qua action only | Soft delete only |
wallet_km2_lot_deduction | user (NV) | Không cấp direct select. Audit trả về qua action khi user có quyền xem khách/đơn | Qua action only | Không | Không |
wallet_km2_lot_deduction | admin | deleted_at IS NULL | Qua action only | Không | Không |
wallet_km2_config | user (NV) | deleted_at IS NULL (read-only) | Không | Không | Không |
wallet_km2_config | admin | deleted_at IS NULL | Có | Có | Soft delete only |
prepaid_card (cột mới) | user | Select: có (wallet_target, expiry_months) | Không | Không | — |
prepaid_card (cột mới) | admin | Có | Có | Có | — |
product (cột mới) | admin | Có | Có | Có (allow_promo_wallet_2) | — |
service (cột mới) | admin | Có | Có | Có (allow_promo_wallet_2) | — |
Quy tắc bảo mật quan trọng
- FIFO deduction PHẢI chạy trong DB transaction với
SELECT ... FOR UPDATEđể tránh race condition (Tham chiếu: DEC-004, RSK-001) - Idempotency bắt buộc cho
deduct_km2_payment: cùngpayment_attempt_idUUID chỉ có 1 row active trongwallet_km2_payment_attempt(UNIQUE INDEXuq_km2_payment_attempt_id); cross-check verify (order_id,customer_id,user_id) chống bypass FE; retry trả kết quả cũ, không trừ ví lần 2, nhưng cùng attempt vẫn được ghi nhiều deduction rows theo nhiều lot (Tham chiếu: DEC-022 + DEC-028) - Expiry guard bắt buộc trong payment action: không deduct lot có
expired_at <= NOW()dù scheduler chưa chạy (Tham chiếu: DEC-022) - Hoàn ví KM2 dùng
refund_km2_wallettrong Yêu cầu hoàn tiền hiện có — handler không hard-code role; phải checkrefund_request_management_submenu:access/create/update/approve/payment, reviewer config,portal,branch_modevà quyền xem customer/lot (Tham chiếu: DEC-026) - wallet_km2_lot INSERT/UPDATE chỉ qua Go action handler — không cho phép direct GraphQL mutation để đảm bảo integrity (lot creation chỉ khi topup, deduction chỉ khi payment/refund/expiry)
- Cross-branch usage không đồng nghĩa cross-branch visibility: Khách mua Gói Ví KM2 chi nhánh A, dùng được chi nhánh B qua Go action sau khi kiểm tra quyền customer/order.
branch_idtrên lot phục vụ report, không được dùng để mở direct select toàn bảng cho roleuser(Tham chiếu: DEC-014, DEC-024) - Config source Day-1: Query đúng 1 record global
branch_id IS NULL, được bảo vệ bằnguq_wallet_km2_config_global_row; active config được bảo vệ thêm bằnguq_wallet_km2_config_global_active. Migration seeddisabled=true; nếu chưa có config active -> Ví KM 2 disabled. PO/Admin bậtdisabled=falsetrong go-live. Branch override chỉ mở lại sau UAT nếu cần. - Dynamic Permission no-leak: FE ẩn menu/button theo permission, nhưng API/action phải tự enforce least-data. Grant/revoke phải được QA bằng refresh/relogin và direct API call.
C9) Yêu cầu phi chức năng (NFR)
Yêu cầu hiệu năng
| Chỉ số | Mục tiêu | Đo bằng | Ghi chú |
|---|---|---|---|
| FIFO deduction (payment_order) | < 200ms | Go benchmark + Hasura logs | Bao gồm lock + update lots + insert deductions |
| Lot list per customer | < 100ms | Hasura query log | Index idx_wallet_km2_lot_user_status |
| KM2 config query | < 50ms | Hasura query log | Table nhỏ, query đơn giản |
| Expiry scheduler (per batch) | < 30s cho 1000 lots | Go scheduler log | Batch update, không lock từng record |
| Report summary (Phase 3) | < 500ms | SQL function EXPLAIN ANALYZE | SQL function với index |
Chiến lược index
| Index | Table | Columns | Mục đích |
|---|---|---|---|
idx_wallet_km2_lot_user_status | wallet_km2_lot | (user_id, status, activated_at ASC) WHERE deleted_at IS NULL | FIFO query, danh sách lần mua Ví KM2 per customer |
idx_wallet_km2_lot_expiry | wallet_km2_lot | (status, expired_at) WHERE status = 'active' AND deleted_at IS NULL | Expiry scheduler |
idx_wallet_km2_lot_branch | wallet_km2_lot | (branch_id) WHERE deleted_at IS NULL | Report theo chi nhánh |
uq_km2_payment_attempt_id | wallet_km2_payment_attempt | (payment_attempt_id) WHERE deleted_at IS NULL | Idempotency header cho retry/double submit (DEC-028) |
idx_km2_payment_attempt_lock | wallet_km2_payment_attempt | (lock_expires_at) WHERE deleted_at IS NULL AND status='processing' | Cron quét lock expired để rollback record về 'failed' |
idx_km2_payment_attempt_ttl | wallet_km2_payment_attempt | (requested_at) WHERE deleted_at IS NULL AND status IN ('completed','failed') | Cron nightly cleanup record > 7 ngày |
idx_km2_payment_attempt_order | wallet_km2_payment_attempt | (order_id) WHERE deleted_at IS NULL | Tra cứu batch theo đơn hàng |
idx_lot_deduction_lot | wallet_km2_lot_deduction | (lot_id) WHERE deleted_at IS NULL | Query deductions per lot |
idx_lot_deduction_order | wallet_km2_lot_deduction | (order_id) WHERE deleted_at IS NULL | Refund: tìm deductions của đơn hàng |
uq_lot_deduction_payment_attempt_lot | wallet_km2_lot_deduction | (payment_attempt_record_id, lot_id, type) WHERE deleted_at IS NULL AND payment_attempt_record_id IS NOT NULL AND type = 'deduction' | Cho phép 1 payment attempt trừ nhiều lot nhưng không duplicate cùng lot |
uq_wallet_km2_config_global_row | wallet_km2_config | (branch_id IS NULL) WHERE branch_id IS NULL AND deleted_at IS NULL | Chỉ 1 global config row, kể cả khi đang disabled=true |
uq_wallet_km2_config_global_active | wallet_km2_config | (branch_id IS NULL) WHERE branch_id IS NULL AND disabled = FALSE AND deleted_at IS NULL | Chỉ 1 global active config |
Ước lượng scale
| Metric | Hiện tại | 1 năm | Ghi chú |
|---|---|---|---|
wallet_km2_lot rows | 0 | ~10K - 50K | 100 khách x 2 Gói Ví KM2/tháng x 12 tháng = 2.4K (spa nhỏ) đến 50K (chuỗi 10+ chi nhánh) |
wallet_km2_payment_attempt rows | 0 | ~10K - 80K | Tương ứng số lần thanh toán bằng KM2; dùng làm idempotency header |
wallet_km2_lot_deduction rows | 0 | ~50K - 200K | Trung bình 5-10 deduction per lot |
wallet_km2_config rows | 1 | 1 - 5 | Day-1 chỉ 1 record global; tăng thêm nếu phase sau bật branch override |
Scale < 100K records cho hầu hết spa. Chưa cần materialized view hay partitioning. Đánh giá lại khi vượt 100K lots.
Capacity Model & Index Strategy chi tiết (BẮT BUỘC trước Phase 2)
Ước lượng tăng trưởng theo phân khúc
| Phân khúc | Số spa | Đơn KM 2 / spa / tháng | qty trung bình / đơn | Lot mới / năm | Deduction / năm | Tổng lot 3 năm |
|---|---|---|---|---|---|---|
| Spa nhỏ (1-2 chi nhánh) | 60% (60 spa) | 50 | 1 | 36K | 250K | ~110K |
| Spa vừa (3-10 chi nhánh) | 30% (30 spa) | 200 | 1.5 | 108K | 850K | ~330K |
| Chuỗi (>10 chi nhánh) | 10% (10 spa) | 800 | 2 | 192K | 1.5M | ~580K |
| Tổng (100 spa active) | ~336K/năm | ~2.6M/năm | ~1M lot, ~8M deduction |
Trigger reevaluation: đến giữa Phase 2 nếu tổng
wallet_km2_lot> 500K, bật materialized view + partition theobranch_idchowallet_km2_lot_deduction.
Index strategy chi tiết per query pattern
| Query pattern | Frequency | Cần index | Justification |
|---|---|---|---|
FIFO deduction: WHERE customer_id=? AND status='active' AND expired_at > NOW() ORDER BY activated_at ASC | High (mỗi lần thanh toán) | idx_km2_lot_fifo partial: (customer_id, activated_at) WHERE status='active' | Partial index giảm 70% size; cover ORDER BY tránh sort |
Tổng số dư khách: SELECT SUM(balance) WHERE customer_id=? AND status='active' | High (mỗi mở profile + chip eligibility check) | Cùng index idx_km2_lot_fifo cover | Index include balance? Postgres tự include cho heap fetch |
Cron expire daily: WHERE status='active' AND expired_at < NOW() FOR UPDATE | 1×/ngày, scan nặng | idx_km2_lot_expiry partial: (expired_at) WHERE status='active' | Partial index cho cron quét; full scan tránh được |
Audit deduction theo order: WHERE order_id=? ORDER BY created_at | Medium (refund + audit) | idx_km2_deduction_order: (order_id, created_at) | Hoàn về lot khi refund đơn |
Audit deduction theo lot: WHERE lot_id=? ORDER BY created_at | Low (drawer SCR-06) | idx_km2_deduction_lot: (lot_id, created_at) | Drawer mở lịch sử lot |
Report top khách dùng nhiều: GROUP BY customer_id, SUM(amount) trong khoảng | Low (Phase 3) | Partition theo branch_id + created_at_month khi > 5M dòng | Aggregation đắt; defer đến Phase 3 |
Idempotency lookup: WHERE payment_attempt_id=? | High | UUID PK đã đủ | — |
Migration index DDL (Day-0)
sql
-- wallet_km2_lot
CREATE INDEX CONCURRENTLY idx_km2_lot_fifo
ON wallet_km2_lot (customer_id, activated_at)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX CONCURRENTLY idx_km2_lot_expiry
ON wallet_km2_lot (expired_at)
WHERE status = 'active' AND deleted_at IS NULL;
CREATE INDEX CONCURRENTLY idx_km2_lot_branch
ON wallet_km2_lot (branch_id, status, activated_at); -- report theo branch
-- wallet_km2_lot_deduction
CREATE INDEX CONCURRENTLY idx_km2_deduction_order
ON wallet_km2_lot_deduction (order_id, created_at);
CREATE INDEX CONCURRENTLY idx_km2_deduction_lot
ON wallet_km2_lot_deduction (lot_id, created_at);
-- wallet_km2_payment_attempt
CREATE INDEX CONCURRENTLY idx_km2_attempt_cleanup
ON wallet_km2_payment_attempt (status, requested_at)
WHERE status IN ('completed', 'failed'); -- cho cron cleanupCONCURRENTLY bắt buộc — production DB không được lock table khi thêm index.
Performance targets
| Operation | Target P95 | Target P99 | Action nếu vượt |
|---|---|---|---|
deduct_km2_payment (full transaction) | < 300ms | < 800ms | Bật query plan analyze, kiểm tra SELECT FOR UPDATE lock contention |
get_customer_km2_lots (max 50 lot) | < 150ms | < 400ms | Verify partial index hit |
| Cron expire daily (full scan) | < 60s/100K lot | < 180s | Partition khi > 5M lot |
| Report aggregate (Phase 3) | < 2s | < 5s | Materialized view |
Partition strategy (kích hoạt khi wallet_km2_lot_deduction > 5M dòng)
Partition theo created_at range monthly:
sql
CREATE TABLE wallet_km2_lot_deduction_2027_01 PARTITION OF wallet_km2_lot_deduction
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');- Cron tạo partition mới mỗi tháng (job
pg_partmanhoặc custom script). - Old partition > 24 tháng có thể move sang archive table hoặc detach.
Quyết định kích hoạt partition: TL review hàng quý. Trigger condition: row count > 5M HOẶC P95 query > 1s.
C10) Quan sát vận hành
Log
| Event | Level | Format | Khi nào |
|---|---|---|---|
| KM2 lot created | INFO | {"event": "km2_lot_created", "user_id": "...", "lot_id": "...", "amount": ..., "expired_at": "..."} | Sau khi tạo lot trong transaction_insert |
| KM2 FIFO deduction | INFO | {"event": "km2_fifo_deduction", "user_id": "...", "order_id": "...", "total_deducted": ..., "lots_affected": [...]} | Sau khi trừ FIFO thành công |
| KM2 lot expired | INFO | {"event": "km2_lot_expired", "lot_id": "...", "balance_cancelled": ...} | Scheduler đánh dấu hết hạn |
| KM2 refund to lot | INFO | {"event": "km2_refund_to_lot", "lot_id": "...", "order_id": "...", "amount": ..., "extended": true/false} | Refund đơn DV hoàn vào lot |
| KM2 wallet refunded | INFO | {"event": "km2_wallet_refunded", "lot_id": "...", "request_id": "...", "refund_amount": ..., "method": "..."} | Hoàn ví KM2 |
| KM2 deduction failed | ERROR | {"event": "km2_deduction_failed", "user_id": "...", "order_id": "...", "error": "..."} | FIFO deduction thất bại |
| KM2 scheduler error | ERROR | {"event": "km2_scheduler_error", "batch_size": ..., "error": "..."} | Expiry scheduler gặp lỗi |
Chỉ số
| Chỉ số | Loại | Nhãn | Mục đích |
|---|---|---|---|
km2_lots_created_total | Counter | branch_id, prepaid_card_id | Tracking số Gói Ví KM2 bán ra |
km2_deductions_total | Counter | branch_id | Tracking số lần thanh toán KM2 |
km2_deduction_amount_total | Counter | branch_id | Tracking tổng tiền trừ KM2 |
km2_lots_expired_total | Counter | — | Tracking số lot hết hạn |
km2_expired_balance_total | Counter | — | Tracking tổng tiền bị xóa do hết hạn |
km2_fifo_duration_seconds | Histogram | — | Đo thời gian FIFO deduction |
km2_scheduler_duration_seconds | Histogram | — | Đo thời gian scheduler chạy |
Cảnh báo
| Alert | Condition | Severity | Channel |
|---|---|---|---|
| FIFO deduction chậm | km2_fifo_duration_seconds p99 > 500ms (5 phút) | Warning | Slack #backend-alerts |
| Scheduler thất bại | km2_scheduler_error > 0 trong 1 giờ | Critical | Slack #backend-alerts + PagerDuty |
| Số dư cháy bất thường | km2_expired_balance_total tăng > 10M/ngày | Warning | Slack #business-alerts |
Metric Catalog đầy đủ + Threshold (BẮT BUỘC trước Day-0)
Bộ này là contract giữa BE + Ops + QA. Mọi alert PHẢI có log query để debug + runbook action.
Business metrics (Prometheus + Grafana)
| Metric name | Type | Labels | Threshold cảnh báo | Action khi vượt |
|---|---|---|---|---|
km2_lots_active_total | Gauge | branch_id | — (chỉ track) | Dashboard công khai |
km2_balance_active_total_vnd | Gauge | branch_id | — | Dashboard |
km2_payment_count_total | Counter | branch_id, result (success/fail) | — | Dashboard |
km2_payment_amount_total_vnd | Counter | branch_id | — | Dashboard |
km2_lot_sold_count_total | Counter | branch_id, package_code | — | Dashboard |
km2_lot_expired_count_daily | Gauge | branch_id | tăng > 50% so với 7 ngày trung bình | Slack #business-alerts |
km2_expired_balance_lost_total_vnd | Counter | branch_id | tăng > 10M/ngày 1 spa | Slack #business-alerts |
km2_refund_request_count_total | Counter | branch_id, refund_method | tăng đột biến > 200% so với baseline | Slack #business-alerts |
Reliability metrics (CRITICAL — page on-call)
| Metric name | Type | Threshold | Severity | Runbook |
|---|---|---|---|---|
km2_double_deduction_detected_total | Counter | > 0 (tuyệt đối) | Critical / page | RUNBOOK-KM2-01 — dừng wallet_km2_config.disabled=true, query log lot_id bị trừ 2 lần, manual rollback deduction |
km2_orphan_deduction_total | Gauge | > 0 | Critical | RUNBOOK-KM2-02 — wallet_km2_lot_deduction có lot_id không tồn tại; data integrity broken |
km2_lot_negative_balance_total | Gauge | > 0 | Critical | RUNBOOK-KM2-03 — lot có balance < 0; rollback deduction gần nhất |
km2_idempotent_replay_rate | Gauge | > 5% (15 phút) | Warning | Network/POS retry quá nhiều, kiểm tra mạng chi nhánh |
km2_attempt_lock_timeout_total | Counter | > 10/giờ | Warning | Worker crash hoặc lock TTL quá ngắn |
km2_expired_lot_used_in_payment_total | Counter | > 0 | Critical | RUNBOOK-KM2-04 — bug trong expiry guard; expired lot bị trừ |
Latency metrics (P95/P99)
| Metric name | Type | Threshold P95 | Threshold P99 | Action |
|---|---|---|---|---|
km2_deduct_payment_duration_seconds | Histogram | > 500ms (5 phút) | > 1.5s (5 phút) | Warning Slack; check DB lock contention |
km2_get_customer_lots_duration_seconds | Histogram | > 200ms | > 500ms | Verify index hit |
km2_scheduler_run_duration_seconds | Histogram | > 60s/100K lot | > 180s | Cân nhắc partition |
km2_scheduler_lag_minutes | Gauge | > 10 phút | > 30 phút | Critical — cron không chạy đúng giờ |
Error rate metrics
| Metric name | Window | Threshold | Severity |
|---|---|---|---|
km2_deduction_error_rate | 5 phút | > 0.1% | Warning |
km2_deduction_error_rate | 5 phút | > 1% | Critical |
km2_scheduler_error_total | 1 giờ | > 0 | Critical |
km2_refund_handler_error_rate | 15 phút | > 1% | Warning |
Log query để debug từng metric
| Metric | Log query (Loki/CloudWatch) |
|---|---|
km2_double_deduction_detected_total | {event="km2_double_deduction"} | json | line_format "{{.lot_id}} {{.order_id}} {{.attempt_id}}" |
km2_orphan_deduction_total | SQL: SELECT lot_id FROM wallet_km2_lot_deduction WHERE lot_id NOT IN (SELECT id FROM wallet_km2_lot) |
km2_lot_negative_balance_total | SQL: SELECT id, balance FROM wallet_km2_lot WHERE balance < 0 |
km2_idempotent_replay_rate | {event="km2_fifo_deduction"} | json | idempotent_replay="true" |
km2_expired_lot_used_in_payment_total | {event="km2_fifo_deduction"} | json | lot_expired_at < requested_at |
Dashboard tối thiểu (Grafana — 1 dashboard KM2 Operations)
- Row 1: 4 KPI big-number — Active lots / Active balance VND / Payment success last 1h / Refund pending
- Row 2: Time series 7 ngày — payment count / payment amount / lot sold count / lot expired count
- Row 3: Latency heatmap —
deduct_km2_paymentP50/P95/P99 - Row 4: Error rate panel — payment error / scheduler error / refund error
- Row 5: Critical incident counters (mọi cái đều = 0 trong điều kiện bình thường) — double_deduction / orphan / negative_balance / expired_lot_used
Runbook stub (BẮT BUỘC tạo trước Day-0)
| Runbook | Khi nào trigger | Action ngay |
|---|---|---|
RUNBOOK-KM2-01 Double deduction | km2_double_deduction_detected > 0 | 1) Tắt wallet_km2_config.disabled=true qua SCR-01. 2) Query wallet_km2_lot_deduction group by (lot_id, order_id) HAVING count > 1. 3) Manual delete deduction trùng + restore lot.balance. 4) Báo PO/Sếp. |
RUNBOOK-KM2-02 Orphan deduction | km2_orphan_deduction > 0 | 1) Snapshot DB. 2) Manual investigate — usually bug migration race; restore từ backup. |
RUNBOOK-KM2-03 Negative balance | km2_lot_negative_balance > 0 | 1) Tắt config. 2) Query lot âm, trace deduction gần nhất, rollback. |
RUNBOOK-KM2-04 Expired lot used | km2_expired_lot_used > 0 | 1) Tắt config. 2) Bug trong handler — review code expired_at > NOW() check. 3) Patch + redeploy. |
RUNBOOK-KM2-05 Scheduler không chạy | km2_scheduler_lag > 30 phút | 1) Check Hasura cron status. 2) Manual trigger qua Hasura UI. 3) Kiểm tra log handler. |
Acceptance Day-0: tất cả metric trên đã instrument trong code; Grafana dashboard tồn tại; 5 runbook đã viết stub (không cần đầy đủ, chỉ cần action steps). Đây là gate TG-006 evidence.
C11) Danh sách việc triển khai
Gate kỹ thuật gắn với task
Các gate này là điều kiện review trước merge staging/UAT. Chi tiết acceptance nằm trong handoff.md; dev-spec chỉ map gate vào task để team không bỏ sót khi chia việc.
| Gate | Task liên quan | Điểm kỹ thuật phải được review |
|---|---|---|
| TG-001 Hoàn ví KM2 | P2-04, P2-05 | refund_km2_wallet đi qua Yêu cầu hoàn tiền hiện có, không hard-code VND_PROMOTION, cập nhật đúng lot/deduction/audit |
| TG-002 Hard-code KM1/KM2 | P1-04, P1-05, P1-06, P1-07, P1-11, P2-03, report/fund/print tasks | Grep và phân loại toàn bộ wallet_promotion, VND_PROMOTION, wallet_type_id; không còn occurrence chưa xử lý trong affected features |
| TG-003 FIFO/idempotency/expiry | P1-08, P1-09, P1-10 | Transaction lock, idempotency key, guard expired_at > NOW(), retry/race test |
| TG-004 DB/Hasura/codegen | P1-01, P1-02, P1-03, P2-07, P3-01, P3-02 | Migration/metadata/codegen pass; action/field KM2 tồn tại đúng contract |
| TG-005 Dynamic Permission v2 | P1-03, P1-12, P2-04, P2-05, P3-02 | module_permission_action, role_module.actions, ModuleOperationMapping, FE visibility và backend enforcement đồng bộ |
| TG-006 Scheduler/rollback/monitoring | P2-06, P2-07, C10 | Cron dry-run, disable config/cron rollback, metric/log/alert có owner |
| TG-007 Impact boundary regression | P1/P2/P3 QA | Affected features có KM2 đúng chỗ; unaffected features không bị mở scope hoặc đổi semantics |
Phase 1 — Core (Bán Gói Ví KM2 + Thanh toán) — Ước lượng: 12 ngày
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P1-01 | DB Migration: wallet tables (lot, payment attempt header, deduction, config, seed) | 0.5d | — | BE |
| P1-02 | DB Migration: ecommerce ALTER (prepaid_card, product, service) | 0.5d | — | BE |
| P1-03 | Hasura metadata: table tracking, permissions, relationships | 1d | P1-01, P1-02 | BE |
| P1-04 | pkg/store/wallet.go: thêm VND_PROMOTION_2 vào CheckWalletBalanceEnough + GetWalletTypeFromPaymentMethod | 0.5d | P1-01 | BE |
| P1-05 | pkg/store/transaction_request_user.go + role_commission.go: thêm constants | 0.5d | P1-01 | BE |
| P1-06 | pkg/store/invoice_payment.go: thêm wallet_promotion_2 vào skip list | 0.5d | P1-04 | BE |
| P1-07 | pkg/store/invoice.go: thêm WalletPromotion2Amount field | 0.5d | P1-06 | BE |
| P1-08 | wallet-api/event/transaction_insert.go: rẽ nhánh VND_PROMOTION_2 — tạo N lots | 1.5d | P1-01, P1-03 | BE |
| P1-09 | ecommerce-api/action/payment_order.go: max% validation + FIFO deduction + deduction records | 2d | P1-04, P1-08 | BE |
| P1-10 | ecommerce-api/action/order_confirm.go: thêm VND_PROMOTION_2 balance check | 0.5d | P1-04 | BE |
| P1-11 | ecommerce-api/event/invoice_insert_update.go: thêm wallet_promotion_2_amount logic | 1d | P1-07 | BE |
| P1-12 | wallet-api/action/get_customer_km2_lots.go: đọc lot sau khi kiểm tra quyền customer/branch | 1d | P1-01, P1-03 | BE |
| P1-13 | FE: SettingsKM2Config.tsx (trang cấu hình) | 1d | P1-03 | FE |
| P1-14 | FE: PrepaidCardForm.tsx (thêm wallet_target, expiry_months) | 0.5d | P1-02 | FE |
| P1-15 | FE: NormalPayment.tsx + OrderAddMultipleInvoiceDialog.tsx (payment method KM2) | 1.5d | P1-09 | FE |
Phase 2 — Customer-facing (Profile + Refund) — Ước lượng: 8 ngày
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P2-01 | FE: StatisticCustomerWallets.tsx (thêm card Ví KM 2) | 0.5d | P1-03 | FE |
| P2-01B | FE: StatisticWalletPromotion2Popup.tsx (mới — popup lịch sử giao dịch Ví KM 2, parity StatisticWalletPromotionPopup.tsx); query useGetTransactionQuery filter wallet_type_id: { _eq: "VND_PROMOTION_2" } (SCR-06-NEW-03, DEC-031 Option α) | 1d | P2-01, P1-03 | FE |
| P2-01C | FE: CustomerEWalletInformation.tsx — thêm instance StatisticWalletPromotion2Popup + handle trigger từ card balance KM 2 / nút "Xem lịch sử" trong CustomerKm2WalletPopup | 0.5d | P2-01B | FE |
| P2-02 | FE: StatisticWalletKM2Tab.tsx (danh sách lần mua Ví KM2 + cảnh báo hết hạn qua get_customer_km2_lots) | 1.5d | P1-12, P2-01 | FE |
| P2-03 | ecommerce-api/event/delivery_update_status.go: refund KM2 logic (hoàn vào lot cũ) | 1.5d | P1-09 | BE |
| P2-04 | wallet-api/action/refund_km2_wallet.go: handler Hoàn ví KM2 trong luồng Yêu cầu hoàn tiền | 2d | P1-08 | BE |
| P2-05 | FE: RefundKM2WalletRequestDialog.tsx (form tạo yêu cầu Hoàn ví KM2) | 1d | P2-04 | FE |
| P2-06 | wallet-api/scheduler/event.go: expire-km2-lots handler | 1d | P1-01 | BE |
| P2-07 | Hasura cron trigger metadata cho expire-km2-lots | 0.5d | P2-06 | BE |
| P2-MOBILE-01 | Mobile shared: thêm WalletType.promotion2('VND_PROMOTION_2') vào core/lib/common/constants/server_constants.dart:300-346 + extension walletLocalized case promotion2 → trans.promotionWallet2 (SCR-09-MOBILE-03) | 0.5d | P1-01 | Mobile |
| P2-MOBILE-02 | Mobile shared: thêm key promotionWallet2: "Ví khuyến mãi 2" vào 4 file ARB (customer intl_vi.arb:166 + intl_en.arb:166, staff intl_vi.arb:914 + intl_en.arb:914) + chạy gen_localization.sh regen | 0.5d | P2-MOBILE-01 | Mobile |
| P2-MOBILE-03 | Mobile shared: regen .g.dart (balance.g.dart, staff.g.dart, wallet_balance.g.dart) — thêm mapping promotion2 ↔ 'VND_PROMOTION_2' (qua build_runner) | 0.5d | P2-MOBILE-01 | Mobile |
| P2-MOBILE-04A | Customer app: extend wallet_screen.dart:213-227 thêm tab thứ 3 "Ví khuyến mãi 2" cùng cấp 2 tab hiện hữu. Dev tự chốt 1 trong 3 phương án: (1) refactor FolderTabs mở rộng 3 tab — core/lib/presentation/common_widget/folder_tabs.dart cần đụng controller (accept value 0/1/2), _buildPositionedTab layout 3 vị trí, _FolderTabPainter 3 folder shape, regression test KPI/Tet/RevenueKpi screen; (2) swap FolderTabs → CustomTabbar (chip pill — core/lib/presentation/common_widget/custom_tabbar.dart đã có); (3) swap FolderTabs → TabPageWidget (Material — đã có) (SCR-09-MOBILE-01, DEC-031) | 2d (P1) / 1d (P2/P3) | P2-MOBILE-01, P1-12 | Mobile |
| P2-MOBILE-04B | Customer app: tạo file mới promotion_wallet_2_page.dart (parity promotion_wallet_page.dart hiện có) — content tab Ví khuyến mãi 2: balance, 2 bảng lot, filter chip, drawer detail (SCR-09-MOBILE-01) | 1.5d | P2-MOBILE-04A, P1-12 | Mobile |
| P2-MOBILE-04C | Customer app: extend wallet_bloc.dart:43-62 thêm 3 event class (GetKm2BalanceEvent, GetKm2TransactionsEvent, ChangeKm2FilterEvent) + 3 state field (km2Balance, km2TransactionsInMonths, km2Filter) + nhánh WalletType.promotion2 trong _onFilter — pattern copy y nguyên từ promotion | 0.5d | P2-MOBILE-04A | Mobile |
| P2-MOBILE-04D | Customer app: extend _buildCard (line 149-200 wallet_screen.dart) switch balance display theo 3 tab index 0/1/2 (thay vì ternary 0/else hiện có) | 0.25d | P2-MOBILE-04A | Mobile |
| P2-MOBILE-05 | Customer app: thêm card "Ví KM 2" thứ 5 vào home_wallet.dart:49-77 (sau bonusPoint card) — conditional state.promotion2?.balance > 0; tap → push mở wallet_screen.dart với initialTabIndex trỏ tab Ví KM 2 (SCR-09-MOBILE-02) | 0.5d | P2-MOBILE-04A | Mobile |
| P2-MOBILE-06 | Customer app: extend home_page_interactor.impl.dart:87-134 thêm getWalletBalances(type: WalletType.promotion2, ...) vào Future.wait song song với promotion hiện có | 0.5d | P2-MOBILE-01 | Mobile |
| P2-MOBILE-07 | Customer app: deeplink handler — ZNS km2_lot_activated tap → mở wallet_screen.dart với tab Ví khuyến mãi 2 active | 0.5d | P2-MOBILE-04A | Mobile |
| P2-MOBILE-08 | Staff app: extend staff/lib/data/data_source/remote/customer/customer_repository.impl.dart:473-481 thêm 1 call _walletRepo.getBalances(type: WalletType.promotion2, userId: id) song song call promotion hiện có; UI customer detail thêm 1 row "Ví khuyến mãi 2" conditional balance > 0 (SCR-10-STAFF-01) | 1d | P2-MOBILE-01 | Mobile |
| P2-MOBILE-09 | Mobile QA: chạy enum mapping test + l10n test + integration test cho screen mới + staff detail balance | 1d | P2-MOBILE-04, P2-MOBILE-08 | QA |
Phase 3 — Report Dashboard — Ước lượng: 5 ngày
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P3-01 | DB: SQL function get_km2_report_summary | 1d | Phase 2 hoàn thành | BE |
| P3-02 | Hasura: function tracking + permissions | 0.5d | P3-01 | BE |
| P3-03 | FE: ReportKM2Dashboard.tsx (trang chính) | 0.5d | P3-02 | FE |
| P3-04 | FE: KM2SummaryCards.tsx (4 KPI cards) | 0.5d | P3-03 | FE |
| P3-05 | FE: KM2ByWalletPackageTypeTable.tsx | 0.5d | P3-03 | FE |
| P3-06 | FE: KM2ExpiringTable.tsx | 0.5d | P3-03 | FE |
| P3-07 | FE: KM2TopCustomersTable.tsx | 0.5d | P3-03 | FE |
| P3-08 | Integration test report | 1d | P3-04 - P3-07 | QA |
Sơ đồ phụ thuộc
P1-01 (wallet migration) ─────────┬──────────────────────────────────────────┐
P1-02 (ecommerce migration) ──┐ │ │
▼ ▼ │
P1-03 (Hasura metadata) │
│ │
┌────────────────────┼────────────────┐ │
▼ ▼ ▼ │
P1-04 (store/wallet) P1-12 (FE config) P1-13 (FE prepaid) │
│ │
┌────┼────────────┐ │
▼ ▼ ▼ │
P1-05 P1-06 P1-10 │
(constants) (invoice_payment) (order_confirm) │
│ │ │
│ ▼ │
│ P1-07 (invoice struct) │
│ │ │
│ ▼ │
│ P1-11 (invoice event) │
│ │
└──────────┐ │
▼ │
P1-08 (transaction_insert rẽ nhánh) │
│ │
▼ │
P1-09 (payment_order FIFO) ─────────────┐ │
│ ▼ │
│ P1-14 (FE payment) │
│ │
┌──────────┼──────────────────────────────────┘ │
▼ ▼ │
P2-03 P2-04 (refund_km2_wallet) ──► P2-05 (FE refund request) │
(delivery │
refund) │
│
P2-01 (FE wallet card) ──► P2-02 (FE danh sách lần mua Ví KM2 tab) │
│
P2-06 (scheduler) ◄─────────────────────────────────────────────────────┘
P2-07 (cron metadata) ◄── P2-06
│
▼
═══════ Phase 2 Hoàn thành ═══════
│
▼
P3-01 (SQL function) ──► P3-02 (Hasura) ──► P3-03..07 (FE report)
│
▼
P3-08 (QA test)C12) Truy vết
Mapping FR -> FE -> BE -> TC
| FR | FE component | BE artifact | TC |
|---|---|---|---|
| FR-001 | SettingsKM2Config.tsx (MỚI) | wallet_km2_config table + Hasura CRUD | TC-001-* |
| FR-002 | PrepaidCardForm.tsx (SỬA) | prepaid_card ALTER + Hasura metadata | TC-002-* |
| FR-003 | PrepaidOrderCreate.tsx (SỬA nhỏ) | wallet-api/event/transaction_insert.go (rẽ nhánh tạo lots) | TC-003-* |
| FR-004 | NormalPayment.tsx + OrderAddMultipleInvoiceDialog.tsx (SỬA) | ecommerce-api/action/payment_order.go (FIFO + max% + deduction records) | TC-004-* |
| FR-005 | ProductForm/ServiceForm (SỬA nhỏ) | product/service ALTER | TC-005-* |
| FR-006 | StatisticWalletKM2Tab.tsx (MỚI) | Action get_customer_km2_lots kiểm tra quyền khách/branch rồi mới query lot | TC-006-* |
| FR-007 | — | wallet-api/scheduler/event.go handler + Hasura cron trigger | TC-007-* |
| FR-008 | Reuse refund flow | ecommerce-api/event/delivery_update_status.go (refund KM2 logic) | TC-008-* |
| FR-009 | RefundKM2WalletRequestDialog.tsx (MỚI) + màn Yêu cầu hoàn tiền hiện có | wallet-api/action/refund_km2_wallet.go + transaction_request.behavior_id='refund_km2_wallet' | TC-009-* |
| FR-010 | ReportKM2Dashboard.tsx (MỚI) | SQL function get_km2_report_summary + Hasura query | TC-010-* |
| FR-011 | — | ZNS trigger on_km2_wallet_activated + on_km2_wallet_expiring_7d | TC-011-* |
| FR-012 | PrepaidOrderPayments + PrepaidOrderPaymentTable + OrderPayments (sub_invoices) | print_invoice_popup.go + print_invoice_preview.go + notification_queue.go | TC-012-* |
Mapping quyết định -> triển khai
| DEC | Triển khai | Cách verify |
|---|---|---|
| DEC-001 | wallet_type INSERT 'VND_PROMOTION_2', tách biệt với VND_PROMOTION | Query SELECT * FROM wallet_type — xác nhận 2 dòng riêng biệt |
| DEC-002 | prepaid_card.wallet_target + prepaid_card.expiry_months cho phép nhiều Gói Ví KM2 | Tạo 2+ Gói Ví KM2 (Silver/Gold) với giá và hạn khác nhau |
| DEC-003 | wallet_km2_config.max_percent_per_order — 1 config chung | Thanh toán 2 đơn khác Gói Ví KM2, xác nhận cùng max% |
| DEC-004 | wallet_km2_lot table + FIFO qua ORDER BY activated_at ASC | Mua 2 Gói Ví KM2, thanh toán 1 đơn — verify lot cũ trừ trước |
| DEC-005 | wallet-api/scheduler/event.go: expire-km2-lots set status = 'expired', trừ wallet balance | Tạo lot hết hạn, chạy scheduler, verify status + balance = 0 |
| DEC-006 | StatisticWalletKM2Tab.tsx: text cảnh báo, không push notification | Xem profile khách có lot sắp hết hạn — verify text hiện, không có push |
| DEC-007 | prepaid_card.wallet_target rẽ nhánh trong transaction_insert.go | Mua Gói Ví KM2 -> verify lot created. Mua thẻ VND -> verify flow cũ không bị ảnh hưởng |
| DEC-008 | wallet_km2_config.allow_combine_km1 toggle logic trong FE payment | Tắt toggle: chọn KM2 -> KM1 biến mất. Bật toggle: cả 2 hiện |
| DEC-009 | product.allow_promo_wallet_2 + service.allow_promo_wallet_2 | Đơn có mix SP eligible + không eligible -> verify chỉ tính max% trên eligible |
| DEC-010 | delivery_update_status.go: query deductions -> hoàn vào lot cũ, gia hạn nếu hết hạn | Refund đơn DV đã dùng KM2: verify lot cũ được cộng lại, lot hết hạn được gia hạn |
| DEC-011 | refund_km2_wallet.go: tính refund_ratio x balance - refund_fee | Hoàn ví KM2 Gold: verify số tiền khách nhận đúng công thức |
| DEC-012 | wallet_km2_lot_deduction INSERT mỗi lần trừ/hoàn | Query deductions per order: verify đúng lot_id, amount, type |
| DEC-013 | User có customer_management:access xem tab KM2 qua action get_customer_km2_lots sau khi pass quyền truy cập profile khách | Login user được grant quyền -> xem profile khách thuộc scope -> thấy tab Ví KM2; khách ngoài phạm vi -> bị chặn |
| DEC-014 | FIFO deduction KHÔNG check branch_id của lot khi redeem; visibility vẫn theo quyền customer/branch | Mua Gói Ví KM2 chi nhánh A, thanh toán chi nhánh B -> verify trừ được qua action; GraphQL user không đọc toàn bộ lot |
| DEC-015 | Lot expired_at set bằng end_of_day: DATE_TRUNC('day', activated_at + expiry_months * INTERVAL '1 month') + INTERVAL '1 day' - INTERVAL '1 second' | Tạo lot, verify expired_at = 23:59:59 ngày cuối cùng |
| DEC-016 | Snapshot package_name, package_price, wallet_value trong wallet_km2_lot | Sửa prepaid_card sau khi bán, verify lot cũ không đổi |
| DEC-017 | Không cho mua Gói Ví KM2 bằng trả góp | Đơn có Gói Ví KM2 -> payment UI/API không cho chọn trả góp |
| DEC-018 | deduct_km2_payment dùng direct PostgreSQL tx + SELECT FOR UPDATE | Chạy 2 request song song, verify 1 request chờ/rollback đúng |
| DEC-019 | Multi-payment credit theo tỉ lệ đã trả | Trả 60% rồi 40%, verify credited_amount tăng đúng và không vượt initial_amount |
| DEC-020 | Profile L có go-live-checklist.md | Handoff trỏ go-live, release gate E1 pass trước deploy |
| DEC-021 | Settings route reuse /s/internal-settings/promotion-wallet, report route theo PD-001 | Verify FE không hardcode route report chưa chốt trong Phase 1/2 |
| DEC-022 | Idempotency + expiry guard trong FIFO deduction | Retry cùng payment_attempt_id không trừ lần 2; lot expired_at <= NOW() không bị deduct |
| DEC-023 | Migration seed wallet_km2_config.disabled = true; PO/Admin bật ở go-live; unique index bảo vệ 1 global row và 1 active global row | Sau migration không có active config; sau E4.7 có đúng 1 active global config |
| DEC-024 | Không cấp role user direct select trên lot/deduction; đọc qua action có access check | Metadata không có select_permissions role user cho lot/deduction; test Staff không đọc được khách ngoài phạm vi |
| DEC-025 | Dynamic Permission v2 matrix + ModuleOperationMapping | Grant/revoke từng action, refresh/relogin, verify UI hidden và direct API no-leak |
| DEC-026 | refund_km2_wallet behavior trong Yêu cầu hoàn tiền hiện có | Tạo request "Hoàn ví KM2", duyệt qua màn refund request, verify lot/wallet/customer refund đúng |