Skip to content

v1.11 — 15/05/2026

Thay đổiSectionẢ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 tabC11) TasksMobile, PM
Thêm P2-01B/C (web admin) — tạo popup StatisticWalletPromotion2Popup.tsx + integrate vào CustomerEWalletInformation.tsxC11) TasksFE 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.xMobile, Ops

v1.10 — 15/05/2026

Thay đổiSectionẢ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) TasksMobile, PM
Thêm step 12 vào "Thứ tự triển khai" — FE Deploy Flutter staff cho SCR-10 customer detail balance KM 2C9.xMobile, Ops
Lưu ý 3 — release core package shared phải đồng bộ giữa Flutter customer + staff app (WalletType enum + l10n)C9.xMobile, Ops

v1.8 — 15/05/2026

Thay đổiSectionẢ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ấtC4) Mô hình dữ liệuBE, QA
DEC-029: Thêm action get_customer_km2_balance realtime cho UI auto-fill + chip eligibility check5.3.1 Metadata Hasura — Action: get_customer_km2_balanceBE, FE
Update FORMULA-001: source = available_amount từ action mới (KHÔNG dùng wallet.amount)C3) Quy tắc / Công thứcBE, FE
Update pseudocode DeductKM2Payment: payment_attempt_id UUID + status enum processing/completed/failed/expired + cross-check verify (order_id, customer_id, user_id)C5.4BE
Lint vietnamese clean: chuẩn hoá calque headings + section title sang tiếng Việt canonicalToàn fileNone

Đặ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ên SOURCE_OF_TRUTH.md. Day-1 lock: wallet_km2_config rollout theo global-first, migration seed disabled=true; branch_id chỉ 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)

FileVai trò
SOURCE_OF_TRUTH.mdNguồn sự thật chuẩn + khóa giải pháp
EVIDENCE_PACK.mdBằng chứng code/screen/db/config thật
prd.mdQuy ước nghiệp vụ, DEC, FR/AC và công thức A10
decision-brief.mdCửa vào package, chỉ tóm tắt và trỏ sang file chịu trách nhiệm
Design docDesign intent ban đầu, không thắng SOURCE_OF_TRUTH.md

C1) Phạm vi

Modules ảnh hưởng

ModuleLoạiThay đổi
wallet-apiBELot creation (transaction_insert), FIFO deduction, expiry scheduler, refund_km2_wallet handler
ecommerce-apiBEPayment max% validation, order confirm balance check, invoice KM2 amount, delivery refund
pkg/storeBE (shared)Wallet type constants, payment method mapping, invoice struct, invoice payment skip list
controller (Hasura)DB/Metadata4 tables mới, 3 ALTER, permissions, cron trigger, relationships
settingsFEConfig Ví KM 2, mở rộng PrepaidCardForm
ecommerceFEPayment method wallet_promotion_2, auto-fill max%, toggle KM1+KM2
userFETab Ví KM 2 trong profile khách, danh sách lần mua Ví KM2, cảnh báo hết hạn
reportFEDashboard 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ửaRủi ro
wallet_type + payment_gatewayVND, VND_PROMOTIONINSERT VND_PROMOTION_2 + payment_gateway mappingLow — seed data bổ sung
prepaid_card tableTable hiện cóALTER ADD wallet_target, expiry_monthsLow — cột bổ sung
product / service tablesTable hiện cóALTER ADD allow_promo_wallet_2Low — cột bổ sung
wallet_km2_lotKhông cóCREATE TABLE mới + 3 indexesLow — table mới
wallet_km2_payment_attemptKhông cóCREATE TABLE mới làm idempotency header cho payment attemptMedium — bảo vệ retry/double submit
wallet_km2_lot_deductionKhông cóCREATE TABLE mới + indexes theo lot/order/payment attemptLow — table mới
wallet_km2_configKhô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ầnLow — table mới
pkg/store/wallet.goHardcode IN list VND, VND_PROMOTIONThêm VND_PROMOTION_2 vào 2 hàmHigh — logic chia sẻ nhiều flow
pkg/store/invoice_payment.goHardcode skip listThê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.goInvoice structThêm WalletPromotion2Amount fieldMedium — struct được dùng rộng rãi
ecommerce-api/action/payment_order.goPayment flowThêm max% validation, FIFO deduction, deduction recordsHigh — core payment logic
ecommerce-api/action/order_confirm.goBalance checkThêm VND_PROMOTION_2 balance checkMedium — order confirmation flow
ecommerce-api/event/invoice_insert_update.goWallet transaction logicThêm wallet_promotion_2_amount handlingMedium — event chain
ecommerce-api/event/delivery_update_status.goRefund logicThêm refund KM2 vào lot cũMedium — refund flow
wallet-api/event/transaction_insert.goTopup 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.goKhô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 transactionHigh — chống double submit/retry/scheduler miss
wallet-api/scheduler/event.goPlaceholderThêm expire-km2-lots handlerLow — handler mới
ecommerce-api/action/payment_order.goPayment 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ếpHigh — core payment
FR-012 — Hiển thị KM2 trong đơn hàng hiện có:
ecommerce-api/action/print_invoice_popup.goSwitch 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.goTương tự print_invoice_popupThêm case "wallet_promotion_2"Medium
pkg/store/notification_queue.goSwitch case payment method (dòng 445)Thêm "wallet_promotion_2" → return "Ví KM 2"Low
FE: PrepaidOrderPayments.tsxSummary: "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.tsxBả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 mappingThêm wallet_promotion_2 → icon + "Ví KM 2"Low — label mapping
FR-012 — Đơn mỹ phẩm (Phase 1):
FE: CosmeticOrderFormPayment.tsxinvoiceIntoPromotionWalletThêm invoiceIntoPromotion2Wallet cho KM2Medium
FE: CosmeticOrderFormPaymentTable.tsxwallet_promotion_amountThêm wallet_promotion_2_amountMedium
FE: CosmeticOrderPaymentFormMultiple.tsxwallet_promotion_amountThêm KM2 vào thanh toán nhiều lầnMedium
FR-012 — Đơn sản phẩm (Phase 1):
FE: ProductOrderCreate.tsxcustomerWalletValue injectThêm VND_PROMOTION_2 vào injectLow
FE: ProductOrderItems.tsxallow_promo_wallet checkThêm allow_promo_wallet_2 checkLow
FR-012 — Invoice template (Phase 1):
FE: InvoiceTemplatePopupPrint.tsxPayment method displayThêm wallet_promotion_2 labelLow
FE: InvoiceTemplatePreview.tsxPayment method displayThêm wallet_promotion_2 labelLow
FR-012 — Fund/Quỹ (Phase 1):
FE: FundTable.tsxwallet_promotion_amountThêm wallet_promotion_2_amountLow
FE: FundInvoicePopup.tsxwallet_promotion_amountThêm wallet_promotion_2_amountLow
FR-012 — Withdraw/Refund (Phase 2):
FE: WithdrawRequestDetail.tsxPayment method labelThêm wallet_promotion_2 → "Ví KM 2"Low
FR-012 — Report DV/NV (Phase 2):
FE: ServiceReportTable.tsx + ServiceGroupReportTable.tsxwallet_promotion_revenueThêm cột wallet_promotion_2_revenueMedium
FE: EmployeeRevenueReportDoughnutChart.tsxtotal_wallet_promotion_amountThêm KM2 vào biểu đồMedium
BE: report_service.graphql + report_employee_revenue.graphqlQuery fieldsThêm KM2 fieldsMedium
FR-012 — CRM Customer (Phase 2):
FE: CustomerEWalletInformation.tsxWallet displayThêm số dư Ví KM 2Low

C2.1 Ma trận impact theo tính năng hiện hữu

Tính năng hiện hữuFile/contract cần chạmDelta triển khai cho KM2Không chạm
KM1 wallet/paymentpkg/store/wallet.go, invoice_payment.go, payment_method, wallet_typeThêm VND_PROMOTION_2 / wallet_promotion_2, mapping balance/skip list riêngKhông đổi VND_PROMOTION, wallet_promotion, dữ liệu KM1
Settings Ví khuyến mãiPromotionWallet.tsx, wallet_km2_config, payment_methodThêm block config KM2 + readiness; seed disabled=trueKhông tạo route settings mới
Thẻ trả trướcprepaid_card, prepaid_card_view, PrepaidCardForm.tsxThêm wallet_target, expiry_months; validate Gói Ví KM2Không đổi logic thẻ VND/KM1
Đơn nạp tiềnPrepaidOrderCreate.tsx, PrepaidOrderForm*, transaction_insert.go, invoice_insert_update.goCho bán Gói Ví KM2, summary KM2, tạo/update lot sau thanh toán, cấm trả gópKhông tạo checkout app/customer self-serve
Thanh toán đơn DV/SPpayment_order.go, order_confirm.go, OrderPayments.tsx, ProductOrderCreate.tsxThêm payment method KM2, max%, eligibility, action deduct_km2_payment, idempotencyKhông đổi flow tiền mặt/chuyển khoản/thẻ/KM1
Product/Service detailProductDetail.tsx, ServiceDetailDetail.tsx, DB product/serviceThêm allow_promo_wallet_2 và permission internal_configuration:updateKhông dùng allow_promo_wallet cho KM2
Customer profileCustomerDetail.tsx, CustomerEWalletInformation.tsx, action get_customer_km2_lotsThêm danh sách lần mua Ví KM2, expiry, audit, CTA Hoàn ví KM2; backend enforce customer scopeKhông cấp direct select wallet_km2_lot cho role user
Yêu cầu hoàn tiềnWithdrawRequestCreate/Detail/Table, WithdrawForm, transaction_request, changeStatusTransaction, handler refund_km2_walletThê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/paymentKhông dựng approval module riêng; không dùng tên legacy cũ
Refund đơn đã dùng KM2delivery_update_status.go, wallet_km2_lot_deductionQuery deduction để hoàn đúng lot; expired thì gia hạn, refunded thì tạo lot mớiKhông hoàn KM2 về VND hoặc VND_PROMOTION
Hoá đơn/print/templateprint_invoice_popup.go, print_invoice_preview.go, InvoiceTemplatePopupPrint.tsx, InvoiceTemplatePreview.tsxThêm label/amount/code "Ví KM2"; biến mới nếu cầnKhông đổi biến KM1 hiện hữu
Fund/actual revenue/ERP/exportFundTable.tsx, FundInvoicePopup.tsx, report_sales_revenue.go, export_report_erp.go, report SQL/functionsThê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ậtKhông cộng value_into_wallet vào thực thu
Report DV/NV/prepaidreport_service.graphql, report_employee_revenue.graphql, PrepaidCardReport*, service report componentsThêm wallet_promotion_2_*, label/cột riêng, export riêngKhông đổi nghĩa wallet_promotion_* của KM1
Notification/ZNS/SMSnotification_queue.go, transaction_insert.go, notification migrations/templatesThêm biến/template KM2 nếu cần thông báo nạp Gói Ví KM2, thanh toán, expiryKhông reuse wallet_promotion_amount cho KM2
Permission/Authauth/server/config.go, module_permission_action, role_module.actions, Hasura metadataMap operations theo Dynamic Permission v2 cho config/prepaid/payment/customer/refund/reportKhô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ăngKết luậnDev note
Rank/loyaltyKhông thêm rule hạng riêngChỉ bảo đảm payment method KM2 nằm trong skip list giống wallet/KM1 để không tính points/actual revenue
Appointment bookingKhông đổi lịch hẹn/trạng thái lịchChỉ 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ốnKhông đổi stock movementKM2 là payment method, không phải item/stock
CRM assignment/pipelineKhông đổi owner/pipeline/chăm sócChỉ thêm hiển thị KM2 khi pass customer permission
Affiliate/referral/CMS gift/eventKhông dùng KM2 làm reward/voucherKhông thêm wallet receive type hoặc campaign gift bằng KM2 trong scope này
Chấm công/HR cơ bảnKhông đổi rule chấm công/lịch làmEmployee 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_config như KM 1 (gate ở services/ecommerce-api/event/invoice_insert_update.go:1287-1298InsertInvoiceAffiliate). 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_id khác nhau. Gate InsertInvoiceAffiliate chạ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.tsx tự 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.md D4).

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 đọc wallet.amount trực tiếp. wallet.amount chỉ 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):

    1. NV mở payment screen → FE gọi action get_customer_km2_balance(customer_id) → nhận available_amount
    2. FE gọi mutation/query compute_order_eligible_total(order_id) (hoặc tính từ order data đã load) → nhận eligible_total
    3. FE load wallet_km2_config.max_percent_per_order (đã cache khi vào POS)
    4. FE tính km2_pay = MIN(eligible_total * max_percent_per_order / 100, available_amount) → auto-fill chip
  • Mapping nguồn:

    • eligible_total ← SUM(order_item.price * order_item.quantity) WHERE product.allow_promo_wallet_2 = TRUE
    • max_percent_per_orderwallet_km2_config.max_percent_per_order
    • available_amount ← Action get_customer_km2_balance.available_amount = SUM(wallet_km2_lot.balance) WHERE status='active' AND expired_at > NOW() (DEC-029: realtime, không dùng wallet.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 / 100

  • Chỉ mục: idx_km2_lot_fifo partial (user_id, activated_at) WHERE status='active' cover được Bước 3

  • Ghi chú hiệu năng: Query get_customer_km2_balance < 5ms với 10 lots/khách (case thực tế). Backend deduct_km2_payment tí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:update sửa được cả 2; bước duyệt dùng approve, bước chi tiền dùng payment.
  • Go handler (refund_km2_wallet behavior/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_ratiowallet_km2_lot.package_price / wallet_km2_lot.wallet_value
    • refund_feewallet_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_status cover đượ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 trong wallet-api/action/. Handler dùng sql.DB direct connection (KHÔNG dùng ctx.AdminClient Hasura 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 trong wallet_km2_payment_attempt theo unique key payment_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ũ trong result (replay), không trừ ví lần 2. wallet_km2_lot_deduction vẫ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'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_id khô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

TableDùng choKey columns
walletSố dư ví của kháchuser_id, wallet_type_id, amount
wallet_typeDanh sách loại víid (VND, VND_PROMOTION, ...)
transactionLịch sử giao dịch víwallet_id, amount, behavior_id
transaction_requestRequest giao dịch (trigger event)behavior_id, wallet_type_id, metadata
transaction_request_userUser mapping cho transactionuser_id, wallet_type_id

Database: ecommerce

TableDùng choKey columns
orderĐơn hàngid, user_id, branch_id, total
order_itemChi tiết đơn hàngorder_id, product_id, price, quantity
invoiceHoá đơn thanh toánorder_id, payment_method
prepaid_cardThẻ trả trướcid, value, value_into_wallet
productSản phẩmid, allow_promo_wallet
serviceDịch vụid, allow_promo_wallet
payment_gatewayPhương thức thanh toánid, name
payment_gateway_wallet_typeMapping gateway <-> wallet typepayment_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_value là snapshot từ prepaid_card tạ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_amount tă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êng payment_attempt_id để ON CONFLICT hoạ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: id là 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 qua payment_attempt_id (UNIQUE) hoặc id (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_permissions cho role user trên wallet_km2_lot. Màn customer profile phải đọc qua backend action/query có kiểm tra customer_management:access, customer_management:view_all, portalbranch_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_permissions cho role user trên wallet_km2_payment_attempt. Idempotency result chỉ trả về qua action deduct_km2_payment sau 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_permissions cho role user trên wallet_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ền customer_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 tra input.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 admin

Handler 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ạnhQuy ước
FormatUUID v4, gen client-side khi NV bấm chip Ví KM 2 (không phải khi bấm Thanh toán)
Scope1 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
PersistLưu trong store FE orderPaymentStore.km2AttemptId[order_id]; reset khi đóng tab order hoặc submit thành công
Truyền BEGửi trong payload deduct_km2_payment mỗi lần submit (kể cả retry)
Bypass FEBackend 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 đủ):

FieldTypeMục đích
idUUID PKTechnical PK (Diva pattern); KHÔNG dùng làm idempotency key
payment_attempt_idUUID NOT NULL, UNIQUEKey dedup chính (client gen UUID v4)
order_idUUID NOT NULLComposite check khi verify duplicate
user_idUUID NOT NULLNV submit (cross-check verify chống bypass FE)
customer_idUUID NOT NULLKhách bị trừ (cross-check verify)
requested_amountINT8 NOT NULLSố tiền caller yêu cầu trừ
deducted_amountINT8 NOT NULL DEFAULT 0Số tiền BE thực sự trừ (≤ requested khi gặp lot expired race)
statusTEXT NOT NULL, enum processing/completed/failed/expiredLifecycle
resultJSONBKết quả DeductKM2PaymentOutput của lần đầu thành công (replay khi duplicate)
errorJSONB NULLLỗi của lần đầu fail (nếu retry không idempotent)
request_hashTEXT NULLDetect retry cùng key nhưng khác payload
lock_holderUUID NULLUUID worker đang xử lý (chống concurrent retry)
lock_expires_atTIMESTAMPTZ NULLLock TTL (90s từ requested_at)
requested_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Mốc tính TTL dedup window (7 ngày)
created_at, updated_at, deleted_at, created_by, updated_byDiva audit fieldsSoft 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); cron nightly xoá record status='completed' quá 7 ngày.
  • Retry behavior:
    • Duplicate khi status='completed': trả result cached + idempotent_replay=true ngay (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

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:

  1. order_id payload khớp order_id record đầu tiên (nếu duplicate)
  2. customer_id payload khớp
  3. user_id JWT khớp user_id record (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 IDScenarioExpected
TC-IDEMPOTENT-01Bấm Thanh toán 1 lần, BE 200Lot trừ 1 lần; attempt.status='completed'; idempotent_replay=false
TC-IDEMPOTENT-02Bấ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-03Bấm 1 lần, mạng timeout, retry sau 5sLot trừ 1 lần; idempotent_replay=true
TC-IDEMPOTENT-04Crash giữa lúc lock; sau 90s NV retryLock expired → cho retry mới; lot trừ 1 lần (vì transaction lần đầu rollback do crash)
TC-IDEMPOTENT-05NV 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-06NV 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-07Submit thành công; sau 8 ngày retry cùng attempt_idRecord đã 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_payment lê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/admin chỉ là technical role. Handler phải tự resolve effective permission theo refund_request_management_submenu:access/create/update/approve/payment, portal, branch_mode, reviewer config và quyền xem customer/lot. Không hard-code role manager/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: String

Luồng tạo request:

  1. FE mở lot trong customer profile qua get_customer_km2_lots.
  2. User bấm Hoàn ví KM2; FE tạo transaction_request:
    • behavior_id = 'refund_km2_wallet'
    • type = 'W'
    • status = 'R'
    • reference_id = wallet_km2_lot.id
    • customer_id, branch_id, payment_method_id, reference_amount = refund_amount
    • transaction_info hoặc refund_request_info lưu snapshot: lot_id, km2_deduct_amount, refund_amount, refund_method, refund_ratio, refund_fee.
  3. 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".
  4. Khi approver duyệt qua changeStatusTransaction, backend gọi handler refund_km2_wallet để lock lot, trừ KM2 balance, ghi deduction type refund_km2_wallet, và hoàn tiền thật theo refund_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_session

5.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_deduction bằng role user; mọi dữ liệu lot trong customer profile đi qua action get_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ốngMã lỗiTrạng tháiFE xử lý
Số dư KM2 không đủKM2_INSUFFICIENT_BALANCE200 (GraphQL error)Toast lỗi, disable submit
Config disabledKM2_DISABLED200 (GraphQL error)Ẩn option Ví KM 2
Max% vượt giới hạnKM2_MAX_PERCENT_EXCEEDED200 (GraphQL error)Re-calculate, hiển thị đúng số tiền
Lot không tồn tạiKM2_LOT_NOT_FOUND200 (GraphQL error)Toast lỗi
Lot đã expired/refundedKM2_LOT_INVALID_STATUS200 (GraphQL error)Refresh danh sách lần mua Ví KM2
Refund không được phépKM2_REFUND_NOT_ALLOWED200 (GraphQL error)Toast "Cấu hình không cho phép hoàn ví KM2"
Refund quá hạnKM2_REFUND_DEADLINE_PASSED200 (GraphQL error)Toast "Đã quá thời hạn hoàn ví KM2"
Race condition (lot locked)Retry qua DB lockTransparentRequest chờ lock, không cần FE xử lý
Không có item eligibleKM2_NO_ELIGIBLE_ITEMS200 (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ỚI

Logic 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_months

NormalPayment.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

DBLatest existingNew migration(s)
wallet17704390000001770439100000_create_wallet_km2_tables
ecommerce17732000000001773200100000_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ật disabled=false trong 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_method row mới wallet_promotion_2 + seed affiliate_config 3 row default FALSE. Idempotent: dùng ON 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ần EVIDENCE_PACK ghi 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.md timeline). Lưu ý 3: Step 11-12 chia sẻ chung shared WalletType enum + l10n key (core/) — phải release đồng bộ để 2 app build cùng version core package.


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 caseModulePortalActionDefault seedBackend enforcement
Bật/tắt KM2, sửa max%, combine KM1+KM2, refund policyinternal_configurationadminupdateAdmin / owner cấu hình hệ thốngVerify action trước mutation config/payment method; audit updated_by
Tạo/sửa loại Gói Ví KM2 (prepaid_card)internal_configurationadminupdateAdmin / owner cấu hình hệ thốngVerify action trước insert/update prepaid_card.wallet_target=VND_PROMOTION_2
Sửa flag allow_promo_wallet_2 trên SP/DVinternal_configurationadminupdateAdmin / owner cấu hình hệ thốngPOS không sửa; backend/mutation check action nếu đi qua action
Bán Gói Ví KM2prepaid_orderposcreatePOS/Sale/Manager theo portal POSCheck branch/session scope khi tạo order
Thu tiền đơn bán Gói Ví KM2prepaid_orderpospaymentPOS/Sale/Manager theo portal POSKhông cho installment; check payment method visibility
Thanh toán đơn dịch vụ bằng KM2service_orderpospaymentPOS/Sale/Manager theo portal POSdeduct_km2_payment verify order/customer/branch scope, idempotency và expiry guard
Thanh toán đơn mỹ phẩm/sản phẩm bằng KM2product_orderpospaymentPOS/Sale/Manager theo portal POSdeduct_km2_payment verify order/customer/branch scope
Xem tab KM2 trong profile kháchcustomer_managementadmin,pos,crmaccessTheo 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/KM2customer_managementadmin,pos,crmview_allAdmin / role được cấp toàn hệ thốngVẫn phải xét branch_mode; không infer từ tên role
Xem Hoàn ví KM2 requestrefund_request_management_submenuadmin,posaccessKế toán/Manager/Admin theo reviewer configCho list/read request behavior refund_km2_wallet nếu thuộc branch/scope
Tạo Hoàn ví KM2 requestrefund_request_management_submenuadmin,poscreateNgười được tạo yêu cầu hoàn tiềninsert_transaction_request tạo behavior refund_km2_wallet nếu có quyền customer/lot
Sửa Hoàn ví KM2 requestrefund_request_management_submenuadmin,posupdateKế toán/Manager/Admin theo reviewer configChỉ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í KM2refund_request_management_submenuadmin,posapproveReviewer hợp lệ theo cấu hình duyệtchangeStatusTransaction verify reviewer step + branch_mode + behavior refund_km2_wallet
Thực hiện hoàn tiền thậtrefund_request_management_submenuadmin,pospaymentKế toán/Manager/Admin theo reviewer configChỉ sau approve; cash/wallet/bank transfer theo request, audit request log
Report KM2 Phase 3Route report theo PD-001adminaccessManager/Admin khi Phase 3 bậtManager branch scoped; Admin/all-branch chỉ khi view_all/branch_mode cho phép

ModuleOperationMapping / auth mapping cần cập nhật

Module/actionOperations / handlers cần mapGhi chú
internal_configuration:updatewallet_km2_config, payment_method, payment_gateway_wallet_type, prepaid_card, product/service KM2 flag mutationsReuse semantics KM1 settings/prepaid config
customer_management:accessget_customer_km2_lots, customer wallet summary readKhông expose direct lot select toàn bảng
service_order:paymentdeduct_km2_payment cho đơn dịch vụHandler verify order/payment scope
product_order:paymentdeduct_km2_payment cho đơn mỹ phẩm/sản phẩmHandler verify order/payment scope
prepaid_order:createCreate prepaid order có wallet_target=VND_PROMOTION_2Không cho installment
prepaid_order:paymentSettlement/paid invoice cho Gói Ví KM2Tạo lot theo multi-payment rule
refund_request_management_submenu:access/create/update/approve/paymenttransaction_request behavior refund_km2_wallet, insert_transaction_request, changeStatusTransaction, handler refund_km2_walletLabel UI "Hoàn ví KM2"; không dùng action legacy cũ

Bảo mật theo dòng Hasura (Row-Level Security)

BảngRoleSelectInsertUpdateDelete
wallet_km2_lotuser (NV)Không cấp direct select. Đọc qua action get_customer_km2_lots sau khi kiểm tra quyền khách/branchQua action onlyQua action onlyKhông
wallet_km2_lotadmindeleted_at IS NULLQua action onlyQua action onlySoft delete only
wallet_km2_payment_attemptuser (NV)Không cấp direct select. Kết quả retry trả qua action deduct_km2_paymentQua action onlyQua action onlyKhông
wallet_km2_payment_attemptadmindeleted_at IS NULLQua action onlyQua action onlySoft delete only
wallet_km2_lot_deductionuser (NV)Không cấp direct select. Audit trả về qua action khi user có quyền xem khách/đơnQua action onlyKhôngKhông
wallet_km2_lot_deductionadmindeleted_at IS NULLQua action onlyKhôngKhông
wallet_km2_configuser (NV)deleted_at IS NULL (read-only)KhôngKhôngKhông
wallet_km2_configadmindeleted_at IS NULLSoft delete only
prepaid_card (cột mới)userSelect: có (wallet_target, expiry_months)KhôngKhông
prepaid_card (cột mới)admin
product (cột mới)adminCó (allow_promo_wallet_2)
service (cột mới)adminCó (allow_promo_wallet_2)

Quy tắc bảo mật quan trọng

  1. 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)
  2. Idempotency bắt buộc cho deduct_km2_payment: cùng payment_attempt_id UUID chỉ có 1 row active trong wallet_km2_payment_attempt (UNIQUE INDEX uq_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)
  3. 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)
  4. Hoàn ví KM2 dùng refund_km2_wallet trong Yêu cầu hoàn tiền hiện có — handler không hard-code role; phải check refund_request_management_submenu:access/create/update/approve/payment, reviewer config, portal, branch_mode và quyền xem customer/lot (Tham chiếu: DEC-026)
  5. 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)
  6. 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_id trên lot phục vụ report, không được dùng để mở direct select toàn bảng cho role user (Tham chiếu: DEC-014, DEC-024)
  7. Config source Day-1: Query đúng 1 record global branch_id IS NULL, được bảo vệ bằng uq_wallet_km2_config_global_row; active config được bảo vệ thêm bằng uq_wallet_km2_config_global_active. Migration seed disabled=true; nếu chưa có config active -> Ví KM 2 disabled. PO/Admin bật disabled=false trong go-live. Branch override chỉ mở lại sau UAT nếu cần.
  8. 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ằngGhi chú
FIFO deduction (payment_order)< 200msGo benchmark + Hasura logsBao gồm lock + update lots + insert deductions
Lot list per customer< 100msHasura query logIndex idx_wallet_km2_lot_user_status
KM2 config query< 50msHasura query logTable nhỏ, query đơn giản
Expiry scheduler (per batch)< 30s cho 1000 lotsGo scheduler logBatch update, không lock từng record
Report summary (Phase 3)< 500msSQL function EXPLAIN ANALYZESQL function với index

Chiến lược index

IndexTableColumnsMục đích
idx_wallet_km2_lot_user_statuswallet_km2_lot(user_id, status, activated_at ASC) WHERE deleted_at IS NULLFIFO query, danh sách lần mua Ví KM2 per customer
idx_wallet_km2_lot_expirywallet_km2_lot(status, expired_at) WHERE status = 'active' AND deleted_at IS NULLExpiry scheduler
idx_wallet_km2_lot_branchwallet_km2_lot(branch_id) WHERE deleted_at IS NULLReport theo chi nhánh
uq_km2_payment_attempt_idwallet_km2_payment_attempt(payment_attempt_id) WHERE deleted_at IS NULLIdempotency header cho retry/double submit (DEC-028)
idx_km2_payment_attempt_lockwallet_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_ttlwallet_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_orderwallet_km2_payment_attempt(order_id) WHERE deleted_at IS NULLTra cứu batch theo đơn hàng
idx_lot_deduction_lotwallet_km2_lot_deduction(lot_id) WHERE deleted_at IS NULLQuery deductions per lot
idx_lot_deduction_orderwallet_km2_lot_deduction(order_id) WHERE deleted_at IS NULLRefund: tìm deductions của đơn hàng
uq_lot_deduction_payment_attempt_lotwallet_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_rowwallet_km2_config(branch_id IS NULL) WHERE branch_id IS NULL AND deleted_at IS NULLChỉ 1 global config row, kể cả khi đang disabled=true
uq_wallet_km2_config_global_activewallet_km2_config(branch_id IS NULL) WHERE branch_id IS NULL AND disabled = FALSE AND deleted_at IS NULLChỉ 1 global active config

Ước lượng scale

MetricHiện tại1 nămGhi chú
wallet_km2_lot rows0~10K - 50K100 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 rows0~10K - 80KTương ứng số lần thanh toán bằng KM2; dùng làm idempotency header
wallet_km2_lot_deduction rows0~50K - 200KTrung bình 5-10 deduction per lot
wallet_km2_config rows11 - 5Day-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úcSố spaĐơn KM 2 / spa / thángqty trung bình / đơnLot mới / nămDeduction / nămTổng lot 3 năm
Spa nhỏ (1-2 chi nhánh)60% (60 spa)50136K250K~110K
Spa vừa (3-10 chi nhánh)30% (30 spa)2001.5108K850K~330K
Chuỗi (>10 chi nhánh)10% (10 spa)8002192K1.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 theo branch_id cho wallet_km2_lot_deduction.

Index strategy chi tiết per query pattern

Query patternFrequencyCần indexJustification
FIFO deduction: WHERE customer_id=? AND status='active' AND expired_at > NOW() ORDER BY activated_at ASCHigh (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 coverIndex include balance? Postgres tự include cho heap fetch
Cron expire daily: WHERE status='active' AND expired_at < NOW() FOR UPDATE1×/ngày, scan nặngidx_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_atMedium (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_atLow (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ảngLow (Phase 3)Partition theo branch_id + created_at_month khi > 5M dòngAggregation đắt; defer đến Phase 3
Idempotency lookup: WHERE payment_attempt_id=?HighUUID 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 cleanup

CONCURRENTLY bắt buộc — production DB không được lock table khi thêm index.

Performance targets

OperationTarget P95Target P99Action nếu vượt
deduct_km2_payment (full transaction)< 300ms< 800msBật query plan analyze, kiểm tra SELECT FOR UPDATE lock contention
get_customer_km2_lots (max 50 lot)< 150ms< 400msVerify partial index hit
Cron expire daily (full scan)< 60s/100K lot< 180sPartition khi > 5M lot
Report aggregate (Phase 3)< 2s< 5sMaterialized 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_partman hoặ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

EventLevelFormatKhi nào
KM2 lot createdINFO{"event": "km2_lot_created", "user_id": "...", "lot_id": "...", "amount": ..., "expired_at": "..."}Sau khi tạo lot trong transaction_insert
KM2 FIFO deductionINFO{"event": "km2_fifo_deduction", "user_id": "...", "order_id": "...", "total_deducted": ..., "lots_affected": [...]}Sau khi trừ FIFO thành công
KM2 lot expiredINFO{"event": "km2_lot_expired", "lot_id": "...", "balance_cancelled": ...}Scheduler đánh dấu hết hạn
KM2 refund to lotINFO{"event": "km2_refund_to_lot", "lot_id": "...", "order_id": "...", "amount": ..., "extended": true/false}Refund đơn DV hoàn vào lot
KM2 wallet refundedINFO{"event": "km2_wallet_refunded", "lot_id": "...", "request_id": "...", "refund_amount": ..., "method": "..."}Hoàn ví KM2
KM2 deduction failedERROR{"event": "km2_deduction_failed", "user_id": "...", "order_id": "...", "error": "..."}FIFO deduction thất bại
KM2 scheduler errorERROR{"event": "km2_scheduler_error", "batch_size": ..., "error": "..."}Expiry scheduler gặp lỗi

Chỉ số

Chỉ sốLoạiNhãnMục đích
km2_lots_created_totalCounterbranch_id, prepaid_card_idTracking số Gói Ví KM2 bán ra
km2_deductions_totalCounterbranch_idTracking số lần thanh toán KM2
km2_deduction_amount_totalCounterbranch_idTracking tổng tiền trừ KM2
km2_lots_expired_totalCounterTracking số lot hết hạn
km2_expired_balance_totalCounterTracking tổng tiền bị xóa do hết hạn
km2_fifo_duration_secondsHistogramĐo thời gian FIFO deduction
km2_scheduler_duration_secondsHistogramĐo thời gian scheduler chạy

Cảnh báo

AlertConditionSeverityChannel
FIFO deduction chậmkm2_fifo_duration_seconds p99 > 500ms (5 phút)WarningSlack #backend-alerts
Scheduler thất bạikm2_scheduler_error > 0 trong 1 giờCriticalSlack #backend-alerts + PagerDuty
Số dư cháy bất thườngkm2_expired_balance_total tăng > 10M/ngàyWarningSlack #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 nameTypeLabelsThreshold cảnh báoAction khi vượt
km2_lots_active_totalGaugebranch_id— (chỉ track)Dashboard công khai
km2_balance_active_total_vndGaugebranch_idDashboard
km2_payment_count_totalCounterbranch_id, result (success/fail)Dashboard
km2_payment_amount_total_vndCounterbranch_idDashboard
km2_lot_sold_count_totalCounterbranch_id, package_codeDashboard
km2_lot_expired_count_dailyGaugebranch_idtăng > 50% so với 7 ngày trung bìnhSlack #business-alerts
km2_expired_balance_lost_total_vndCounterbranch_idtăng > 10M/ngày 1 spaSlack #business-alerts
km2_refund_request_count_totalCounterbranch_id, refund_methodtăng đột biến > 200% so với baselineSlack #business-alerts

Reliability metrics (CRITICAL — page on-call)

Metric nameTypeThresholdSeverityRunbook
km2_double_deduction_detected_totalCounter> 0 (tuyệt đối)Critical / pageRUNBOOK-KM2-01 — dừng wallet_km2_config.disabled=true, query log lot_id bị trừ 2 lần, manual rollback deduction
km2_orphan_deduction_totalGauge> 0CriticalRUNBOOK-KM2-02 — wallet_km2_lot_deductionlot_id không tồn tại; data integrity broken
km2_lot_negative_balance_totalGauge> 0CriticalRUNBOOK-KM2-03 — lot có balance < 0; rollback deduction gần nhất
km2_idempotent_replay_rateGauge> 5% (15 phút)WarningNetwork/POS retry quá nhiều, kiểm tra mạng chi nhánh
km2_attempt_lock_timeout_totalCounter> 10/giờWarningWorker crash hoặc lock TTL quá ngắn
km2_expired_lot_used_in_payment_totalCounter> 0CriticalRUNBOOK-KM2-04 — bug trong expiry guard; expired lot bị trừ

Latency metrics (P95/P99)

Metric nameTypeThreshold P95Threshold P99Action
km2_deduct_payment_duration_secondsHistogram> 500ms (5 phút)> 1.5s (5 phút)Warning Slack; check DB lock contention
km2_get_customer_lots_duration_secondsHistogram> 200ms> 500msVerify index hit
km2_scheduler_run_duration_secondsHistogram> 60s/100K lot> 180sCân nhắc partition
km2_scheduler_lag_minutesGauge> 10 phút> 30 phútCritical — cron không chạy đúng giờ

Error rate metrics

Metric nameWindowThresholdSeverity
km2_deduction_error_rate5 phút> 0.1%Warning
km2_deduction_error_rate5 phút> 1%Critical
km2_scheduler_error_total1 giờ> 0Critical
km2_refund_handler_error_rate15 phút> 1%Warning

Log query để debug từng metric

MetricLog query (Loki/CloudWatch)
km2_double_deduction_detected_total{event="km2_double_deduction"} | json | line_format "{{.lot_id}} {{.order_id}} {{.attempt_id}}"
km2_orphan_deduction_totalSQL: SELECT lot_id FROM wallet_km2_lot_deduction WHERE lot_id NOT IN (SELECT id FROM wallet_km2_lot)
km2_lot_negative_balance_totalSQL: 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_payment P50/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)

RunbookKhi nào triggerAction ngay
RUNBOOK-KM2-01 Double deductionkm2_double_deduction_detected > 01) 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 deductionkm2_orphan_deduction > 01) Snapshot DB. 2) Manual investigate — usually bug migration race; restore từ backup.
RUNBOOK-KM2-03 Negative balancekm2_lot_negative_balance > 01) Tắt config. 2) Query lot âm, trace deduction gần nhất, rollback.
RUNBOOK-KM2-04 Expired lot usedkm2_expired_lot_used > 01) Tắt config. 2) Bug trong handler — review code expired_at > NOW() check. 3) Patch + redeploy.
RUNBOOK-KM2-05 Scheduler không chạykm2_scheduler_lag > 30 phút1) 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.

GateTask liên quanĐiểm kỹ thuật phải được review
TG-001 Hoàn ví KM2P2-04, P2-05refund_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/KM2P1-04, P1-05, P1-06, P1-07, P1-11, P2-03, report/fund/print tasksGrep 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/expiryP1-08, P1-09, P1-10Transaction lock, idempotency key, guard expired_at > NOW(), retry/race test
TG-004 DB/Hasura/codegenP1-01, P1-02, P1-03, P2-07, P3-01, P3-02Migration/metadata/codegen pass; action/field KM2 tồn tại đúng contract
TG-005 Dynamic Permission v2P1-03, P1-12, P2-04, P2-05, P3-02module_permission_action, role_module.actions, ModuleOperationMapping, FE visibility và backend enforcement đồng bộ
TG-006 Scheduler/rollback/monitoringP2-06, P2-07, C10Cron dry-run, disable config/cron rollback, metric/log/alert có owner
TG-007 Impact boundary regressionP1/P2/P3 QAAffected 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ượngPhụ thuộcPhụ trách
P1-01DB Migration: wallet tables (lot, payment attempt header, deduction, config, seed)0.5dBE
P1-02DB Migration: ecommerce ALTER (prepaid_card, product, service)0.5dBE
P1-03Hasura metadata: table tracking, permissions, relationships1dP1-01, P1-02BE
P1-04pkg/store/wallet.go: thêm VND_PROMOTION_2 vào CheckWalletBalanceEnough + GetWalletTypeFromPaymentMethod0.5dP1-01BE
P1-05pkg/store/transaction_request_user.go + role_commission.go: thêm constants0.5dP1-01BE
P1-06pkg/store/invoice_payment.go: thêm wallet_promotion_2 vào skip list0.5dP1-04BE
P1-07pkg/store/invoice.go: thêm WalletPromotion2Amount field0.5dP1-06BE
P1-08wallet-api/event/transaction_insert.go: rẽ nhánh VND_PROMOTION_2 — tạo N lots1.5dP1-01, P1-03BE
P1-09ecommerce-api/action/payment_order.go: max% validation + FIFO deduction + deduction records2dP1-04, P1-08BE
P1-10ecommerce-api/action/order_confirm.go: thêm VND_PROMOTION_2 balance check0.5dP1-04BE
P1-11ecommerce-api/event/invoice_insert_update.go: thêm wallet_promotion_2_amount logic1dP1-07BE
P1-12wallet-api/action/get_customer_km2_lots.go: đọc lot sau khi kiểm tra quyền customer/branch1dP1-01, P1-03BE
P1-13FE: SettingsKM2Config.tsx (trang cấu hình)1dP1-03FE
P1-14FE: PrepaidCardForm.tsx (thêm wallet_target, expiry_months)0.5dP1-02FE
P1-15FE: NormalPayment.tsx + OrderAddMultipleInvoiceDialog.tsx (payment method KM2)1.5dP1-09FE

Phase 2 — Customer-facing (Profile + Refund) — Ước lượng: 8 ngày

#TaskƯớc lượngPhụ thuộcPhụ trách
P2-01FE: StatisticCustomerWallets.tsx (thêm card Ví KM 2)0.5dP1-03FE
P2-01BFE: 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 α)1dP2-01, P1-03FE
P2-01CFE: CustomerEWalletInformation.tsx — thêm instance StatisticWalletPromotion2Popup + handle trigger từ card balance KM 2 / nút "Xem lịch sử" trong CustomerKm2WalletPopup0.5dP2-01BFE
P2-02FE: StatisticWalletKM2Tab.tsx (danh sách lần mua Ví KM2 + cảnh báo hết hạn qua get_customer_km2_lots)1.5dP1-12, P2-01FE
P2-03ecommerce-api/event/delivery_update_status.go: refund KM2 logic (hoàn vào lot cũ)1.5dP1-09BE
P2-04wallet-api/action/refund_km2_wallet.go: handler Hoàn ví KM2 trong luồng Yêu cầu hoàn tiền2dP1-08BE
P2-05FE: RefundKM2WalletRequestDialog.tsx (form tạo yêu cầu Hoàn ví KM2)1dP2-04FE
P2-06wallet-api/scheduler/event.go: expire-km2-lots handler1dP1-01BE
P2-07Hasura cron trigger metadata cho expire-km2-lots0.5dP2-06BE
P2-MOBILE-01Mobile 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.5dP1-01Mobile
P2-MOBILE-02Mobile 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 regen0.5dP2-MOBILE-01Mobile
P2-MOBILE-03Mobile 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.5dP2-MOBILE-01Mobile
P2-MOBILE-04ACustomer 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 FolderTabsCustomTabbar (chip pill — core/lib/presentation/common_widget/custom_tabbar.dart đã có); (3) swap FolderTabsTabPageWidget (Material — đã có) (SCR-09-MOBILE-01, DEC-031)2d (P1) / 1d (P2/P3)P2-MOBILE-01, P1-12Mobile
P2-MOBILE-04BCustomer 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.5dP2-MOBILE-04A, P1-12Mobile
P2-MOBILE-04CCustomer 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ừ promotion0.5dP2-MOBILE-04AMobile
P2-MOBILE-04DCustomer 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.25dP2-MOBILE-04AMobile
P2-MOBILE-05Customer 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.5dP2-MOBILE-04AMobile
P2-MOBILE-06Customer 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.5dP2-MOBILE-01Mobile
P2-MOBILE-07Customer app: deeplink handler — ZNS km2_lot_activated tap → mở wallet_screen.dart với tab Ví khuyến mãi 2 active0.5dP2-MOBILE-04AMobile
P2-MOBILE-08Staff 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)1dP2-MOBILE-01Mobile
P2-MOBILE-09Mobile QA: chạy enum mapping test + l10n test + integration test cho screen mới + staff detail balance1dP2-MOBILE-04, P2-MOBILE-08QA

Phase 3 — Report Dashboard — Ước lượng: 5 ngày

#TaskƯớc lượngPhụ thuộcPhụ trách
P3-01DB: SQL function get_km2_report_summary1dPhase 2 hoàn thànhBE
P3-02Hasura: function tracking + permissions0.5dP3-01BE
P3-03FE: ReportKM2Dashboard.tsx (trang chính)0.5dP3-02FE
P3-04FE: KM2SummaryCards.tsx (4 KPI cards)0.5dP3-03FE
P3-05FE: KM2ByWalletPackageTypeTable.tsx0.5dP3-03FE
P3-06FE: KM2ExpiringTable.tsx0.5dP3-03FE
P3-07FE: KM2TopCustomersTable.tsx0.5dP3-03FE
P3-08Integration test report1dP3-04 - P3-07QA

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

FRFE componentBE artifactTC
FR-001SettingsKM2Config.tsx (MỚI)wallet_km2_config table + Hasura CRUDTC-001-*
FR-002PrepaidCardForm.tsx (SỬA)prepaid_card ALTER + Hasura metadataTC-002-*
FR-003PrepaidOrderCreate.tsx (SỬA nhỏ)wallet-api/event/transaction_insert.go (rẽ nhánh tạo lots)TC-003-*
FR-004NormalPayment.tsx + OrderAddMultipleInvoiceDialog.tsx (SỬA)ecommerce-api/action/payment_order.go (FIFO + max% + deduction records)TC-004-*
FR-005ProductForm/ServiceForm (SỬA nhỏ)product/service ALTERTC-005-*
FR-006StatisticWalletKM2Tab.tsx (MỚI)Action get_customer_km2_lots kiểm tra quyền khách/branch rồi mới query lotTC-006-*
FR-007wallet-api/scheduler/event.go handler + Hasura cron triggerTC-007-*
FR-008Reuse refund flowecommerce-api/event/delivery_update_status.go (refund KM2 logic)TC-008-*
FR-009RefundKM2WalletRequestDialog.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-010ReportKM2Dashboard.tsx (MỚI)SQL function get_km2_report_summary + Hasura queryTC-010-*
FR-011ZNS trigger on_km2_wallet_activated + on_km2_wallet_expiring_7dTC-011-*
FR-012PrepaidOrderPayments + PrepaidOrderPaymentTable + OrderPayments (sub_invoices)print_invoice_popup.go + print_invoice_preview.go + notification_queue.goTC-012-*

Mapping quyết định -> triển khai

DECTriển khaiCách verify
DEC-001wallet_type INSERT 'VND_PROMOTION_2', tách biệt với VND_PROMOTIONQuery SELECT * FROM wallet_type — xác nhận 2 dòng riêng biệt
DEC-002prepaid_card.wallet_target + prepaid_card.expiry_months cho phép nhiều Gói Ví KM2Tạo 2+ Gói Ví KM2 (Silver/Gold) với giá và hạn khác nhau
DEC-003wallet_km2_config.max_percent_per_order — 1 config chungThanh toán 2 đơn khác Gói Ví KM2, xác nhận cùng max%
DEC-004wallet_km2_lot table + FIFO qua ORDER BY activated_at ASCMua 2 Gói Ví KM2, thanh toán 1 đơn — verify lot cũ trừ trước
DEC-005wallet-api/scheduler/event.go: expire-km2-lots set status = 'expired', trừ wallet balanceTạo lot hết hạn, chạy scheduler, verify status + balance = 0
DEC-006StatisticWalletKM2Tab.tsx: text cảnh báo, không push notificationXem profile khách có lot sắp hết hạn — verify text hiện, không có push
DEC-007prepaid_card.wallet_target rẽ nhánh trong transaction_insert.goMua Gói Ví KM2 -> verify lot created. Mua thẻ VND -> verify flow cũ không bị ảnh hưởng
DEC-008wallet_km2_config.allow_combine_km1 toggle logic trong FE paymentTắt toggle: chọn KM2 -> KM1 biến mất. Bật toggle: cả 2 hiện
DEC-009product.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-010delivery_update_status.go: query deductions -> hoàn vào lot cũ, gia hạn nếu hết hạnRefund đơn DV đã dùng KM2: verify lot cũ được cộng lại, lot hết hạn được gia hạn
DEC-011refund_km2_wallet.go: tính refund_ratio x balance - refund_feeHoàn ví KM2 Gold: verify số tiền khách nhận đúng công thức
DEC-012wallet_km2_lot_deduction INSERT mỗi lần trừ/hoànQuery deductions per order: verify đúng lot_id, amount, type
DEC-013User có customer_management:access xem tab KM2 qua action get_customer_km2_lots sau khi pass quyền truy cập profile kháchLogin 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-014FIFO deduction KHÔNG check branch_id của lot khi redeem; visibility vẫn theo quyền customer/branchMua 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-015Lot 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-016Snapshot package_name, package_price, wallet_value trong wallet_km2_lotSửa prepaid_card sau khi bán, verify lot cũ không đổi
DEC-017Khô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-018deduct_km2_payment dùng direct PostgreSQL tx + SELECT FOR UPDATEChạy 2 request song song, verify 1 request chờ/rollback đúng
DEC-019Multi-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-020Profile L có go-live-checklist.mdHandoff trỏ go-live, release gate E1 pass trước deploy
DEC-021Settings route reuse /s/internal-settings/promotion-wallet, report route theo PD-001Verify FE không hardcode route report chưa chốt trong Phase 1/2
DEC-022Idempotency + expiry guard trong FIFO deductionRetry cùng payment_attempt_id không trừ lần 2; lot expired_at <= NOW() không bị deduct
DEC-023Migration seed wallet_km2_config.disabled = true; PO/Admin bật ở go-live; unique index bảo vệ 1 global row và 1 active global rowSau migration không có active config; sau E4.7 có đúng 1 active global config
DEC-024Không cấp role user direct select trên lot/deduction; đọc qua action có access checkMetadata không có select_permissions role user cho lot/deduction; test Staff không đọc được khách ngoài phạm vi
DEC-025Dynamic Permission v2 matrix + ModuleOperationMappingGrant/revoke từng action, refresh/relogin, verify UI hidden và direct API no-leak
DEC-026refund_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