transactions. Không cần dò qua bảng khác.type) — nhìn vào loại hành động là biết ngay dòng này thuộc câu chuyện tính tiền nào.
Hãy hình dung bảng transactions giống như 1 cuốn sổ nhật ký dùng chung cho mọi hoạt động tiền/lượt trong hệ thống. Mỗi khi có 1 hành động xảy ra — client nạp tiền, khách hàng đổi quà, cửa hàng quét mã — hệ thống viết thêm đúng 1 dòng mới vào sổ này. Không có cuốn sổ phụ nào khác cần lật qua.
Mỗi dòng trong sổ có 4 thông tin quan trọng nhất: ai/việc gì (cột type — loại hành động), bao nhiêu tiền/lượt (cột amount), chế độ tính tiền áp dụng cho dòng này (cột billing_mode — xem giải thích ngay dưới đây), và nếu là dòng "nạp" thì còn có thêm 1 danh sách nhỏ ghi lại những lần đã tiêu từ khoản nạp này (cột childMasterTransactions) — giống như 1 tờ phiếu nạp tiền có ghim kèm theo các biên lai đã xài từ tờ phiếu đó.
transactions, nó tự động "chụp" luôn chế độ đang được cấu hình cho loại quà đó vào đúng dòng này — lưu vào cột billing_mode. DA không cần JOIN sang bảng cấu hình nào cả, chỉ cần đọc thẳng cột đó trên chính dòng transactions.
Công ty Vingroup ký hợp đồng tặng điểm cho nhân viên qua UrBox. Chị Vân (nhân viên) có 1 thẻ quà tặng. Câu chuyện diễn ra như sau:
| STT | Hành động thực tế | type (loại) | amount | source | Loại quà | billing_mode (snapshot sẵn) | childMasterTransactions (chỉ dòng nạp mới có) |
|---|---|---|---|---|---|---|---|
| 1 | Vingroup nạp 50,000đ vào thẻ chị Vân — đây là dòng "cha" | client-prepaid- |
50,000đ | client |
Quà điểm Vingroup | ① |
[ {id: MT-EU-002, amount: 20000},{id: MT-EU-005, amount: 30000}] Danh sách này chỉ để biết "đã tiêu bao nhiêu, còn dư bao nhiêu" — không dùng để tính tiền client phải trả (xem Phần 2) |
| 2 | Chị Vân đổi quà lần 1, tốn 20,000đ — đây là dòng "con" | user-prepaid- |
20,000đ | enduser |
Quà điểm Vingroup | ① |
— (dòng con không có con) |
| 3 | Chị Vân đổi quà lần 2, tốn 30,000đ — cũng là dòng "con" | user-prepaid- |
30,000đ | enduser |
Quà điểm Vingroup | ① |
— |
Ghi chú kỹ thuật: cột "Loại quà" tương ứng đúng gift_detail_config_id gắn trên từng dòng transactions. Cột billing_mode là chế độ đối soát đã snapshot sẵn ngay lúc ghi dòng — DA đọc thẳng cột này, không cần tra bảng nào khác (xem giải thích ở đầu Phần 1).
client — client gọi API (nạp tiền/lượt, đổi quà thẳng). enduser — end-user tự thao tác (đổi quà qua app/web), hoặc hành vi thực tế của end-user được xác nhận qua 1 kênh khác (ví dụ nhân viên Merchant quét mã — bản chất vẫn là xác nhận việc end-user dùng mã, không phải nhân viên "sở hữu" giao dịch). system — hệ thống tự sinh dòng, không ai bấm (ví dụ dòng tự động hết hạn/expire). operation — đội vận hành UrBox thao tác thủ công (ví dụ điều chỉnh số dư khi xử lý khiếu nại). Tài liệu này chủ yếu gặp client và enduser; system/operation nêu ra để đủ ngữ cảnh, không có ví dụ cụ thể trong các use case dưới đây.
Lượt (quantity) khác điểm ở 1 điểm quan trọng: điểm là tiền, tiêu vào đâu cũng được; còn lượt phải gắn với 1 package — hiểu đơn giản, package là "1 gói lượt chỉ dùng để đổi được một số loại quà nhất định trong package đó", giống như 1 cuốn phiếu ăn chỉ dùng được ở đúng chuỗi cửa hàng ghi trên bìa phiếu, không tiêu lung tung được.
Công ty Techcombank mua gói 5 lượt "Voucher Cà phê" (package gồm 2 loại quà: Highlands 50k và Phúc Long 50k) tặng cho anh Long:
| STT | Hành động thực tế | type | amount | source | package | Loại quà đã đổi | billing_mode | childMasterTransactions |
|---|---|---|---|---|---|---|---|---|
| 1 | Techcombank nạp 5 lượt vào Package "Voucher Cà phê" — dòng "cha" | client-catalog- |
5 lượt | client |
PKG-CAFE-01 |
— (dòng nạp, chưa gắn 1 quà cụ thể nào) | — |
[ {id: MT-EU-011, amount: 2},{id: MT-EU-013, amount: 3}] |
| 2 | Anh Long đổi 2 lượt lấy Voucher Highlands 50k — dòng "con" | user-catalog- |
2 lượt | enduser |
PKG-CAFE-01 |
Voucher Highlands | ② |
— |
| 3 | Anh Long đổi 3 lượt lấy Voucher Phúc Long 50k — dòng "con" | user-catalog- |
3 lượt | enduser |
PKG-CAFE-01 |
Voucher Phúc Long | ③ |
— |
transactions.
PKG-CAFE-01) nhưng lại là 2 loại quà khác nhau (Highlands và Phúc Long), nên mỗi dòng snapshot 1 billing_mode riêng — Highlands chốt tiền ngay lúc đổi (② ON_REDEEM_BY_QUANTITY, dòng 2 coi như đã chốt xong 20,000đ tương đương), còn Phúc Long phải đợi tới lúc khách hàng dùng thật tại cửa hàng mới chốt (③ ON_MERCHANT_USE, dòng 3 CHƯA tính vào công nợ cho tới khi có thêm 1 dòng user-merchant-redeem phát sinh sau này). Cột "package" cho biết lượt đến từ đâu; cột "billing_mode" mới là cột quyết định công thức tính tiền.
Đây là tình huống dồn hết các trường hợp khó lại cùng lúc, để thấy cơ chế "chỉ 1 bảng transactions" vẫn đứng vững kể cả khi nghiệp vụ rối nhất. Package trong hệ thống này không lồng cha/con — mỗi package là 1 pool lượt độc lập, gắn thẳng với 1 gift_set cụ thể qua gift_detail_configs.gift_set_id. Công ty Vingroup ký 1 hợp đồng có 2 package song song: PKG-CAFE-01 (đổi được Highlands/Phúc Long) và PKG-FOOD-02 (đổi được Golden Gate/Pizza 4P's) — cả 2 đều được nạp lượt CÙNG LÚC trong 1 lần topup, nhờ topup_quantity_config khai báo type: ALL. Lưu ý: mỗi lần đổi quà chỉ đổi được đúng 1 loại quà — "đổi qua package khác" ở use case này nghĩa là 2 hành động đổi quà độc lập, KHÔNG phải 1 lần gọi gộp nhiều sản phẩm.
Trước khi Vingroup nạp lượt đầu tiên, 2 bên đã chốt sẵn "luật chơi" trên portal quản trị: có 2 gói lượt tách biệt nhau hoàn toàn, mỗi gói chỉ đổi được 1 nhóm quà cố định, và mỗi món quà trong nhóm tốn bao nhiêu lượt đã được ấn định từ trước — đây chính là lý do những con số xuất hiện ở bảng giao dịch phía dưới không phải ngẫu nhiên.
| Gói lượt | Đổi được nhóm quà | Mỗi món trong nhóm tốn bao nhiêu lượt |
|---|---|---|
| Gói "Cà phê" PKG-CAFE-01 |
Bộ Cà phê | Voucher Highlands 50k → 9 lượt |
| Voucher Phúc Long 50k → 6 lượt (không xuất hiện trong ví dụ giao dịch bên dưới, chỉ liệt kê cho đủ) | ||
| Gói "Ẩm thực" PKG-FOOD-02 |
Bộ Ẩm thực | Voucher Golden Gate 100k → 4 lượt |
| Voucher Pizza 4P's 100k → 4 lượt (không xuất hiện trong ví dụ giao dịch bên dưới, chỉ liệt kê cho đủ) |
2 gói này không liên quan gì tới nhau — không có gói lớn chia nhỏ ra 2 gói này, cũng không gói nào là "phụ" của gói kia. Chúng chỉ tình cờ cùng thuộc 1 hợp đồng và được nạp lượt cùng lúc, vì Vingroup thiết lập vậy cho tiện: mỗi lần Vingroup nạp tiền, hệ thống tự động chia 9 lượt vào gói Cà phê và 4 lượt vào gói Ẩm thực trong cùng 1 thao tác — giống như 1 tấm thẻ quà tặng có 2 ngăn riêng, nạp tiền vào thẻ thì cả 2 ngăn đều nhận tiền theo đúng tỷ lệ đã hẹn trước, nhưng tiêu ngăn nào không ảnh hưởng ngăn kia.
Ghi chú kỹ thuật (đối chiếu dữ liệu): quà = gift_details (GD-HL-50, GD-PL-50, GD-GG-100, GD-P4P-100) · nhóm quà = gift_sets (GS-CAFE gồm GD-HL-50+GD-PL-50, GS-FOOD gồm GD-GG-100+GD-P4P-100) · gói lượt = packages (PKG-CAFE-01 → gift_set_id=GS-CAFE, PKG-FOOD-02 → gift_set_id=GS-FOOD, cùng campaign_id=CAMP-VIN-2026, không có quan hệ cha/con) · số lượt mỗi món = gift_set_details.deduct_quantity · cơ chế chia lượt đồng thời = gift_detail_configs.topup_quantity_config = { type: ALL, packages: [{packageId: PKG-CAFE-01, quantity: 9}, {packageId: PKG-FOOD-02, quantity: 4}] }.
2 package hoàn toàn độc lập, không chia sẻ lượt cho nhau. PKG-CAFE-01 chỉ nhận đúng 1 đợt nạp (9 lượt), đủ khớp với 9 lượt Highlands nên không cần vắt nguồn. Ngược lại, PKG-FOOD-02 nhận 2 đợt nạp riêng (4 lượt ở bước ① + 6 lượt ở bước ②) — khi chị Mai đổi 4 lượt Golden Gate, hệ thống trừ theo đúng thứ tự FIFO trong CHÍNH package đó, giống hệt cơ chế đã thấy ở ví dụ điểm (mục 1.1), chỉ khác là ở đây có cột package để phân biệt 2 pool không liên quan nhau. Phần Golden Gate còn đi tiếp 1 bước nữa — được dùng thật tại Merchant, đúng thời điểm chốt tiền của chế độ ③.
| STT | Hành động thực tế | type | amount | source | package | Loại quà | billing_mode | childMasterTransactions |
|---|---|---|---|---|---|---|---|---|
| 1 | Vingroup topup — hệ thống tự tách thành 2 dòng độc lập theo topup_quantity_config: 9 lượt vào PKG-CAFE-01 | client-catalog- |
9 lượt | client |
PKG-CAFE-01 |
— (dòng nạp) | — | — |
| 1b | ...và đồng thời 4 lượt vào PKG-FOOD-02 — cùng 1 lần gọi API, nhưng 2 dòng transactions tách biệt vì 2 package độc lập | client-catalog- |
4 lượt | client |
PKG-FOOD-02 |
— (dòng nạp) | — | — |
| 2 | Vingroup nạp thêm 6 lượt — đợt 2, chỉ định riêng cho PKG-FOOD-02 | client-catalog- |
6 lượt | client |
PKG-FOOD-02 |
— (dòng nạp) | — | — |
| 3 | Chị Mai đổi 9 lượt lấy Voucher Highlands, trừ thẳng từ PKG-CAFE-01 — vừa đúng bằng dòng 1, không cần vắt nguồn nào khác | user-catalog- |
9 lượt | enduser |
PKG-CAFE-01 |
Voucher Highlands | ② |
— |
| 4 | Ở 1 lần gọi RIÊNG khác (không liên quan tới dòng 3), chị Mai đổi 4 lượt lấy Voucher Golden Gate, trừ từ PKG-FOOD-02 — vắt qua 2 đợt nạp của CHÍNH package này: dòng 1b (4 lượt) đã đủ, thực tế không cần chạm tới dòng 2 | user-catalog- |
4 lượt | enduser |
PKG-FOOD-02 |
Voucher Golden Gate | ③ |
— |
| 5 | Vài ngày sau, chị Mai dùng mã Golden Gate tại cửa hàng — nhân viên quét mã, hệ thống ghi 1 dòng mới xác nhận mã đã được redeem tại Merchant (đây mới là lúc chế độ ③ ON_MERCHANT_USE tính tiền) | user-merchant- |
4 lượt | enduser |
PKG-FOOD-02 |
Voucher Golden Gate | ③ |
— |
billing_mode = ② nên dòng 3 coi như đã chốt tiền ngay lúc đổi, còn Golden Gate snapshot billing_mode = ③ nên dòng 4 CHƯA chốt gì cả, phải đợi tới dòng 5 mới thật sự tính vào công nợ. Nếu DA chỉ dựa vào package để gộp nhóm tính tiền sẽ dễ nhầm — phải nhóm theo đúng loại quà (gift_detail_config_id) và đọc đúng cột billing_mode đã snapshot trên từng dòng để biết áp công thức nào.
PKG-CAFE-01 chỉ có đúng 1 đợt nạp (dòng 1) và 1 lần trừ (dòng 3), khớp vừa đủ. PKG-FOOD-02 có 2 đợt nạp (dòng 1b và dòng 2) nhưng vì Golden Gate chỉ cần 4 lượt, đủ nằm gọn trong đợt nạp đầu tiên của chính package này — hoàn toàn không đụng gì tới PKG-CAFE-01. Nếu Golden Gate cần nhiều hơn 4 lượt (ví dụ 8 lượt), lúc đó mới cần vắt sang dòng 2, và childMasterTransactions của dòng 1b + dòng 2 sẽ cùng nhắc tới dòng trừ đó — y hệt cơ chế FIFO đã thấy ở ví dụ điểm, chỉ khác là luôn giới hạn trong phạm vi 1 package, không bao giờ vắt CHÉO sang package khác.
billing_mode của loại quà đó. Việc 2 dòng này cùng xuất hiện trong 1 use case chỉ để minh hoạ: dù không liên quan tới nhau, chúng cùng lấy nguồn lượt từ 1 lần Vingroup nạp tiền ban đầu (bước ①).
Các gói lượt trong hệ thống luôn đứng riêng, không có gói lớn chứa gói nhỏ bên trong. Nhưng vẫn có 1 cách để "1 hành động, nhiều gói cùng nhận lượt": khi 2 bên thống nhất trước rằng 1 lần nạp sẽ tự động chia ra cho nhiều gói theo đúng tỷ lệ đã hẹn — mỗi gói nhận đúng phần của mình, không gói nào giữ hộ phần của gói khác, và không có bước nào phải "đi qua" gói trung gian trước khi tới nơi.
Công ty Sacombank có 2 gói lượt tách biệt hoàn toàn: 1 gói đổi được Voucher Highlands, 1 gói đổi được Voucher Phúc Long — nhưng chỉ cần 1 lần nạp tiền duy nhất là cả 2 gói đều nhận lượt cùng lúc.
Giống use case Vingroup ở trên, Sacombank cũng có 2 gói lượt tách biệt: gói "Cà phê" đổi được Voucher Highlands (9 lượt/lần), gói "Trà" đổi được Voucher Phúc Long (6 lượt/lần) — 2 gói này không hề biết tới sự tồn tại của nhau, chỉ tình cờ cùng 1 hợp đồng.
Điểm khác biệt duy nhất so với ví dụ Vingroup: Sacombank chỉ ký 1 dòng hợp đồng chung ("ngân sách F&B"), và khi nạp tiền, hệ thống tự quy đổi ra 14 lượt cho gói Cà phê + 6 lượt cho gói Trà trong cùng 1 lần bấm nạp — số lượt chia cho từng gói là do 2 bên thống nhất từ đầu, không tự động chia đều hay theo tỷ lệ nào khác.
Ghi chú kỹ thuật (đối chiếu dữ liệu): gói Cà phê = PKG-FNB-CAFE (gift_set_id=GS-CAFE, đổi GD-HL-50), gói Trà = PKG-FNB-TEA (gift_set_id=GS-TEA, đổi GD-PL-50), cùng campaign_id=CAMP-SACOM-2026, không có quan hệ cha/con · cơ chế chia lượt đồng thời = gift_detail_configs.topup_quantity_config = { type: ALL, packages: [{packageId: PKG-FNB-CAFE, quantity: 14}, {packageId: PKG-FNB-TEA, quantity: 6}] }.
| STT | Hành động thực tế | type | amount | source | package | Loại quà | billing_mode | Ghi chú |
|---|---|---|---|---|---|---|---|---|
| 1 | Sacombank topup — hệ thống tự tách theo topup_quantity_config: 14 lượt vào PKG-FNB-CAFE | client-catalog- |
14 lượt | client |
PKG-FNB-CAFE |
— (dòng nạp) | — | Cùng 1 lần gọi API với dòng 1b |
| 1b | ...và đồng thời 6 lượt vào PKG-FNB-TEA | client-catalog- |
6 lượt | client |
PKG-FNB-TEA |
— (dòng nạp) | — | 2 dòng tách biệt vì 2 package độc lập, dù cùng 1 request |
| 2 | Anh Khoa đổi 6 lượt lấy Voucher Highlands — trừ thẳng từ PKG-FNB-CAFE | user-catalog- |
6 lượt | enduser |
PKG-FNB-CAFE |
Voucher Highlands | ② |
Dòng đổi quà thật — chỉ dòng này mới tính vào công nợ |
PKG-FNB-CAFE nhận lượt trực tiếp từ dòng topup (dòng 1) và bị trừ trực tiếp bởi dòng đổi quà (dòng 2) — chỉ 2 dòng, không hơn. PKG-FNB-TEA vẫn còn nguyên 6 lượt, hoàn toàn không bị ảnh hưởng bởi việc anh Khoa đổi Highlands.
Theo đúng nguyên tắc đã dùng xuyên suốt tài liệu (mục 1.1, 1.2, 1.3) — bản thân bảng packages KHÔNG có cột lưu sẵn số dư. Số lượt hiện có của 1 package không phải 1 con số nằm cố định ở đâu đó, mà được suy ra mỗi lần cần biết bằng cách gom lại toàn bộ các dòng transactions đã từng nhắc tới package đó — y hệt cách "còn dư bao nhiêu" của 1 lần topup điểm (mục 1.1) được tính bằng cách cộng childMasterTransactions rồi trừ lùi.
Áp cho đúng 3 dòng đã ghi ở use case Sacombank, PKG-FNB-CAFE có dòng "vào" là dòng 1 (+14) và dòng "ra" là dòng 2 (−6) → còn lại 8 lượt. PKG-FNB-TEA chỉ có đúng dòng "vào" là dòng 1b (+6), chưa có dòng "ra" nào → còn nguyên 6 lượt.
PKG-FNB-CAFE ở tương lai, hệ thống không cần "đọc lại số dư cũ rồi cộng thêm" — chỉ cần ghi thêm 1 dòng transactions mới với cùng package = PKG-FNB-CAFE. Lần sau, bất cứ khi nào cần biết state hiện tại của package nào, chỉ cần chạy lại đúng công thức SUM ở trên, quét toàn bộ lịch sử dòng liên quan tới package đó — không có rủi ro số dư bị lệch giữa "cột lưu sẵn" và "lịch sử giao dịch thật", vì chỉ có duy nhất 1 nguồn sự thật là chính các dòng transactions đã ghi.
sold_quantity: gift_detail_configs có sẵn cột sold_quantity (đếm số code thật đã bán trong campaign, cập nhật bằng optimistic update để chống oversell) — đây là 2 tầng đếm hoàn toàn độc lập, không liên quan tới "số lượt còn lại của package" vừa tính ở trên. sold_quantity đếm số CODE VẬT LÝ đã phát ra so với total_code_quantity (giới hạn tồn kho thật của Inventory), còn số lượt còn lại của package là số dư NGÂN SÁCH/QUYỀN LỢI mà client đã nạp cho end-user — 1 lần đổi quà con hợp lệ về mặt package (còn đủ lượt) vẫn có thể bị chặn ở tầng khác nếu sold_quantity đã chạm total_code_quantity (hết code thật trong kho), và ngược lại.
Số tiền client phải trả UrBox không cố định 1 công thức — nó phụ thuộc vào việc từng loại quà được cấu hình chốt tiền vào lúc nào. Có đúng 4 cách, và mỗi loại quà (mỗi gift_detail_config) tự chọn 1 cách riêng khi thiết lập — không phải cả campaign dùng chung 1 cách.
billing_mode trên từng dòng transactions, không thể giả định cả campaign chỉ có 1 rule — cơ chế snapshot đã nêu ở Phần 1.
Ngay khi client gọi API (nạp tiền/lượt, hoặc đổi quà thẳng cho khách hàng không qua nạp), toàn bộ số tiền của lần gọi đó được coi là đã chốt xong. Khách hàng có dùng hết hay không, dùng lúc nào, không ảnh hưởng gì đến số tiền client đã nợ.
Ví dụ: ở câu chuyện chị Vân (mục 1.1), nếu loại quà "Quà điểm Vingroup" cấu hình chế độ này — Vingroup nợ UrBox đúng 50,000đ ngay từ lúc bấm nạp, bất kể chị Vân có đổi quà hay không.
Client không trả tiền ngay lúc nạp — chỉ khi khách hàng thực sự đổi quà, hệ thống mới tính tiền cho đúng phần đã đổi. Bản chất số tiền chốt luôn là giá trị quà con × số lượng đã đổi. Vì mỗi lần đổi quà chỉ được đúng 1 loại quà (không gộp nhiều sản phẩm trong 1 lần gọi), mỗi hành động đổi quà tương ứng đúng 1 dòng transactions — không có khái niệm "gộp đơn" ở chế độ này.
Ví dụ: ở câu chuyện anh Long (mục 1.2), nếu loại quà "Voucher Highlands" cấu hình chế độ này — Techcombank nợ đúng phần Highlands đã đổi: 2 lượt đã chốt tiền, quy đổi ra tiền theo đơn giá tại thời điểm đổi. Lưu ý: Phúc Long (loại quà khác, trong cùng use case 1.2) hoàn toàn có thể cấu hình 1 chế độ khác — xem ví dụ cụ thể ở bảng mục 1.2, nơi Highlands dùng chế độ ② còn Phúc Long dùng chế độ ③.
Đây là mốc trễ nhất trong các cách: dù khách hàng đã đổi quà và có mã trong tay, client vẫn chưa phải trả tiền — chỉ khi nhân viên cửa hàng quét mã đó thật sự (khách hàng đã dùng), hệ thống mới ghi thêm 1 dòng mới để chốt tiền.
Ví dụ: ở câu chuyện anh Long (mục 1.2), nếu loại quà "Voucher Phúc Long" cấu hình chế độ này — dù anh Long đã đổi 3 lượt lấy Phúc Long, Techcombank chưa nợ đồng nào cho phần đó cho tới khi anh Long thực sự mang mã ra quán và nhân viên quét mã xác nhận đã dùng.
Đây là chế độ riêng cho quà dạng lượt có package/gift_set (mục 1.2, 1.3). "Quà cha" ở đây là cả bộ package/gift_set. "Quà con" là từng gift_detail cụ thể nằm bên trong bộ.
Client không trả tiền ngay lúc nạp. Chỉ khi khách hàng dùng x lượt để đổi 1 quà con bất kỳ trong bộ — bất kể đổi quà con nào, bất kể x là bao nhiêu lượt — hệ thống lập tức chốt tiền cho toàn bộ giá trị quà cha, không phải giá trị riêng của quà con vừa đổi.
Giá trị quà cha không phải 1 con số cố định tra sẵn — theo đúng cấu hình gift_detail_configs.point_price_config/quantity_price_config, giá quà cha có thể đổi theo khung giờ/ngày trong tuần (flash sale). Ngay tại thời điểm dòng đổi quà con phát sinh, hệ thống phải chốt giá theo đúng rule đang khớp tại thời điểm đó (so khớp applicable_day_in_week/applicable_time_frame/start_at/end_at, ưu tiên rule có priority cao hơn nếu nhiều rule cùng khớp; không có rule nào khớp thì lấy base_point/base_quantity mặc định).
Ví dụ: package/gift_set "Combo trải nghiệm" có quantity_price_config khai báo base_point = 500,000đ, kèm 1 rule flash sale giảm còn 400,000đ nếu đổi vào khung 14h-17h thứ Bảy/Chủ nhật. Khách hàng dùng 9 lượt đổi 1 Voucher Highlands (1 quà con trong bộ, giá trị thật chỉ 50,000đ) đúng vào 15h thứ Bảy — hệ thống chốt tiền client nợ theo rule đang khớp, tức 400,000đ (không phải 500,000đ mặc định, và không phải 50,000đ của riêng Highlands). Nếu đổi vào giờ khác không khớp rule nào, tiền chốt quay lại 500,000đ.
| STT | Hành động thực tế | type | amount | source | Loại quà | billing_mode | Số tiền chốt vào công nợ |
|---|---|---|---|---|---|---|---|
| 1 | Khách hàng đổi 9 lượt lấy Voucher Highlands (1 quà con trong bộ "Combo trải nghiệm") đúng vào 15h thứ Bảy | user-catalog- |
9 lượt | enduser |
Voucher Highlands | ④ |
400,000đ — KHÔNG phải amount (9 lượt) hay giá trị Highlands (50,000đ), mà là giá quà cha đã chốt tại đúng 15h thứ Bảy theo rule flash sale đang khớp |
amount (9 lượt) và cột "Số tiền chốt vào công nợ" (400,000đ) hoàn toàn không liên quan nhau — đây chính là điểm khác biệt lớn nhất so với chế độ ②, nơi 2 con số đó gắn chặt với nhau. Khi đối soát, DA không được tra giá quà cha tại thời điểm chạy báo cáo — phải dùng đúng giá đã chốt lúc phát sinh giao dịch (thường được lưu lại thành 1 snapshot trên chính dòng transaction hoặc dòng liên quan, không suy ngược từ cấu hình hiện tại vì cấu hình có thể đã đổi rule sau đó).
Dùng bảng này để tra cứu nhanh: loại quà đang cấu hình chế độ nào, DA lọc đúng billing_mode đó trên bảng transactions để tính công nợ cho riêng loại quà đó — không cần nhớ chi tiết cả 4 chế độ mỗi lần làm báo cáo. Lưu ý: 1 báo cáo công nợ theo campaign thường phải cộng dồn kết quả từ nhiều loại quà, mỗi loại tính theo đúng chế độ riêng của nó.
type: cột "type thường gặp" dưới đây chỉ để hình dung nhanh, KHÔNG phải điều kiện lọc thật. Cùng 1 type (ví dụ user-*-payment) có thể xuất hiện ở NHIỀU billing_mode khác nhau, tuỳ loại quà nào sinh ra dòng đó (xem use case 1.2: cùng type user-catalog-card-payment nhưng Highlands billing_mode = ②, Phúc Long billing_mode = ③). Điều kiện lọc đúng luôn là WHERE billing_mode = ..., không bao giờ là WHERE type = ....
| Chế độ | Thường gặp ở loại quà nào | Chốt tiền lúc nào | type thường thấy (không phải điều kiện lọc) | Đơn vị tính | Rủi ro / Đặc điểm cần nhớ |
|---|---|---|---|---|---|
| ① FULL_ON_REQUEST | Quà điểm/lượt nạp trực tiếp, hoặc quà cần nhà cung cấp bên thứ 3 xác nhận ngay (đặt phòng, đặt bàn — request thành công là coi như chắc chắn xảy ra) | Ngay lúc client gọi API (nạp hoặc đổi thẳng) | client-*-topupclient-redeem-voucher |
Toàn bộ giá trị request | Client trả tiền sớm nhất, kể cả phần khách hàng không dùng hết — rủi ro nghiêng về phía client |
| ② ON_REDEEM_BY_QUANTITY | Quà dạng lượt có package, nơi client muốn trả đúng theo phần khách hàng thực sự đã đổi | Mỗi lần khách hàng đổi 1 quà con — mỗi lần đổi là 1 dòng riêng, đúng giá trị quà đó | user-*-payment |
Giá trị quà con × số lượng đã đổi | Công bằng theo đúng giá trị thực đã dùng — mỗi dòng transaction tương ứng đúng 1 lần đổi 1 loại quà |
| ③ ON_MERCHANT_USE | Voucher ăn uống/dịch vụ tại cửa hàng — nơi mã có thể phát ra nhưng chưa chắc được dùng | Khi cửa hàng quét mã xác nhận khách hàng đã dùng thật | user-merchant-redeem |
1 lần dùng thật tại cửa hàng | Chốt muộn nhất, an toàn nhất cho client — nhưng có thể không bao giờ chốt nếu khách không dùng mã |
| ④ FULL_PARENT_ON_FIRST_REDEEM | Quà dạng combo/bộ (package/gift_set) — client cam kết trả đủ giá trị cả bộ ngay khi khách bắt đầu dùng, bất kể dùng phần nào | Ngay khi đổi 1 quà con bất kỳ trong bộ (package/gift_set), bất kể x lượt là bao nhiêu | user-*-payment (dùng để biết ĐÃ có đổi hay chưa — số tiền chốt KHÔNG lấy từ amount dòng này) |
Toàn bộ giá trị quà cha (cả bộ), tra từ cấu hình package/gift_set — không phải amount của dòng đổi quà | Dễ nhầm với chế độ ② — SUM(amount) trên dòng đổi quà con KHÔNG ra đúng công nợ, phải tra giá trị quà cha riêng |
gift_detail_config) tra ra đúng 1 chế độ ở cột đầu tiên — cột "Thường gặp ở loại quà nào" chỉ là gợi ý phổ biến, không phải luật cứng (2 bên vẫn có thể chọn chế độ khác nếu nghiệp vụ cần). Muốn tính công nợ đầy đủ 1 campaign, DA lọc riêng từng nhóm dòng theo gift_detail_config_id (hoặc trực tiếp theo billing_mode đã snapshot sẵn trên từng dòng), áp đúng công thức của chế độ ứng với loại quà đó, rồi cộng dồn tất cả lại.
SUM(amount) ... WHERE billing_mode = ... trên đúng 1 bảng transactions; riêng chế độ ④ cần thêm 1 bước tra giá trị quà cha từ cấu hình package/gift_set, không chỉ dựa vào transactions. Báo cáo công nợ đầy đủ của 1 campaign = tổng công nợ của TỪNG loại quà, mỗi loại tính theo đúng chế độ riêng.