← Quay lại Gift Flow diagram

Đối soát & Tính tiền Client — Điểm, Lượt, Package

Tài liệu này trả lời 2 câu hỏi: dữ liệu được ghi lại như thế nào mỗi khi client hoặc khách hàng (end-user) thao tác, và từ dữ liệu đó, số tiền client phải trả cho UrBox được tính ra sao — tuỳ theo cách campaign được cấu hình.
Nguyên tắc cốt lõi xuyên suốt tài liệu:
① Mọi dữ liệu cần đối soát đều nằm trên 1 bảng duy nhất — bảng transactions. Không cần dò qua bảng khác.
② Mỗi dòng trong bảng đó có 1 loại hành động (gọi là 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.
③ Có 4 cách tính tiền khác nhau, và cách nào áp dụng được quyết định theo từng loại quà (mỗi loại quà trong campaign có thể chọn 1 cách riêng) — không phải cả campaign chỉ dùng đúng 1 cách. 1 campaign hoàn toàn có thể vừa bán quà điểm (chốt tiền theo chế độ ①), vừa bán voucher (chốt theo chế độ ③) trong cùng 1 hợp đồng.
Mục lục
Phần 1 — Dữ liệu được sinh ra như thế nào  1.1 Ví dụ theo Điểm (balance)  1.2 Ví dụ theo Lượt có Package  1.3 Use case phức tạp nhất — Nhiều package độc lập, vắt nhiều nguồn topup, 2 lần đổi quà tách biệt  1.4 Mở rộng — 1 lần topup đổ đồng thời vào nhiều package độc lập Phần 2 — Cách tính tiền client phải trả (4 chế độ) Phần 3 — Bảng tổng hợp so sánh nhanh

Phần 1 — Dữ liệu được sinh ra như thế 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 đó.

Vậy DA biết áp chế độ nào cho từng dòng bằng cách nào, nếu chế độ tính tiền là do TỪNG LOẠI QUÀ quyết định (không phải cả campaign)? Ngay lúc hệ thống ghi 1 dòng mới vào 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.
Vì sao "chụp lại" (snapshot) quan trọng hơn tra cứu trực tiếp: nếu chỉ tra cấu hình hiện tại mỗi lần đối soát, con số sẽ SAI ngay khi 2 bên đổi rule giữa chừng hợp đồng — ví dụ tháng 1 loại quà X đang chốt theo chế độ ②, tháng 3 đổi sang chế độ ①, thì các dòng giao dịch phát sinh trong tháng 1 vẫn phải tính theo chế độ ② (đúng luật tại thời điểm đó), không được tính lại theo chế độ ① của tháng 3. Snapshot ngay trên dòng transaction đảm bảo lịch sử không bị viết lại, giống hệt nguyên tắc "giá đã chốt không đổi theo cấu hình sau này" đã áp dụng cho chế độ ④ (xem Phần 2).
Công nợ 1 client = với mỗi dòng trong transactions: đọc billing_mode ngay trên dòng đó → áp đúng công thức của chế độ tương ứng (①②③④) → cộng dồn lại theo client/kỳ đối soát

1.1 Ví dụ theo Điểm (balance) — không có package

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:

① Vingroup nạp 50,000đ vào thẻ chị Vân → ② Chị Vân đổi quà lần 1 — 20,000đ → ③ Chị Vân đổi quà lần 2 — 30,000đ
Hành động của Client hoặc End-user
Hệ thống tự xử lý phía sau
Thời điểm tiền được chốt (client nợ UrBox)

Sổ nhật ký (bảng transactions) ghi lại đúng 3 dòng sau:

STTHành động thực tếtype (loại)amountsourceLoạ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-
card-topup
50,000đ client Quà điểm Vingroup ①
FULL_ON_REQUEST
[
 {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-
card-payment
20,000đ enduser Quà điểm Vingroup ①
FULL_ON_REQUEST
— (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-
card-payment
30,000đ enduser Quà điểm Vingroup ①
FULL_ON_REQUEST
—
Đọc bảng này ra sao: hãy tưởng tượng dòng 1 là 1 "phiếu nạp" trị giá 50,000đ. Mỗi lần chị Vân tiêu tiền từ phiếu đó, hệ thống viết thêm 1 "biên lai" (dòng 2, dòng 3) và đồng thời ghim tên biên lai đó vào phiếu nạp gốc. Muốn biết phiếu nạp còn dư bao nhiêu, chỉ cần cộng các biên lai đã ghim lại rồi trừ đi: 50,000 − (20,000+30,000) = 0đ, dùng hết. Cả 3 dòng đều cùng 1 loại quà ("Quà điểm Vingroup"), cùng snapshot 1 chế độ đối soát (① FULL_ON_REQUEST) — ví dụ này chưa cho thấy trường hợp trộn nhiều loại quà/nhiều chế độ, sẽ rõ hơn ở use case 1.3.

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

Cột "source" — ai/cái gì tạo ra dòng này: có 4 giá trị. 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.

1.2 Ví dụ theo Lượt có Package

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:

① Techcombank nạp 5 lượt vào Package "Voucher Cà phê" cho thẻ anh Long → ② Anh Long đổi 2 lượt lấy Voucher Highlands 50k → ③ Anh Long đổi 3 lượt lấy Voucher Phúc Long 50k

Sổ nhật ký ghi lại đúng 3 dòng — chỉ khác ví dụ trên ở đơn vị tính (lượt thay vì tiền) và có thêm cột package:

STTHành động thực tếtypeamountsourcepackageLoại quà đã đổibilling_modechildMasterTransactions
1 Techcombank nạp 5 lượt vào Package "Voucher Cà phê" — dòng "cha" client-catalog-
card-topup
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-
card-payment
2 lượt enduser PKG-CAFE-01 Voucher Highlands ②
ON_REDEEM_BY_
QUANTITY
—
3 Anh Long đổi 3 lượt lấy Voucher Phúc Long 50k — dòng "con" user-catalog-
card-payment
3 lượt enduser PKG-CAFE-01 Voucher Phúc Long ③
ON_MERCHANT_USE
—
Vì sao cần cột package: nếu chỉ nhìn số lượt (5 lượt nạp, 2+3 lượt đã tiêu) thì giống hệt câu chuyện điểm ở trên — không có gì đặc biệt. Cái khác là: package cho biết 5 lượt này chỉ được phép đổi Highlands hoặc Phúc Long, không đổi được sản phẩm khác ngoài package. Cột package trên cả dòng cha và dòng con giúp đối chiếu 2 chiều: "gói lượt này đã cấp cho ai, dùng vào đúng loại quà được phép chưa" — mà vẫn không cần rời khỏi bảng transactions.
Package khác Loại quà — và đây là ví dụ THẬT về 2 billing_mode khác nhau trong cùng 1 package: dòng 2 và dòng 3 cùng thuộc 1 package (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.

1.3 Use case phức tạp nhất — Nhiều package độc lập, vắt nhiều nguồn topup, 2 lần đổi quà tách biệt

Đâ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.

Cấu hình quà — Vingroup và UrBox đã thống nhất những gì trước khi có giao dịch nào xảy ra

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.

Đọc bảng này ra sao: đây là lý do vì sao ở bước giao dịch phía dưới, chị Mai đổi Highlands lại tốn đúng 9 lượt (không phải 1 lượt hay 1 voucher = 1 lượt như hay lầm tưởng) — con số 9 đã được 2 bên thống nhất từ trước cho đúng cặp (gói Cà phê, Highlands). Tương tự, Golden Gate tốn 4 lượt vì đó là số đã thống nhất cho cặp (gói Ẩm thực, Golden Gate). Nếu ngày mai 2 bên đổi thoả thuận, Highlands còn tốn 7 lượt thay vì 9, các giao dịch MỚI sau đó sẽ áp số mới — nhưng giao dịch CŨ đã ghi lại rồi thì giữ nguyên số đã trừ tại đúng thời điểm nó xảy ra, không bị tính lại.

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}] }.

① Vingroup gọi 1 lần topup — hệ thống tự đổ đồng thời 9 lượt vào PKG-CAFE-01 và 4 lượt vào PKG-FOOD-02 cho thẻ chị Mai → ② Vingroup gọi topup đợt 2, đổ thêm 6 lượt vào riêng PKG-FOOD-02 → ③ Chị Mai đổi 9 lượt lấy Voucher Highlands — 1 lần gọi, 1 quà duy nhất, trừ từ PKG-CAFE-01 → ④ Chị Mai đổi 4 lượt lấy Voucher Golden Gate — 1 lần gọi RIÊNG khác, trừ từ PKG-FOOD-02 → ⑤ Vài ngày sau, chị Mai mang mã Golden Gate ra quán, nhân viên quét mã xác nhận đã dùng
Mỗi lần đổi quà chỉ đổi đúng 1 loại quà: hệ thống không hỗ trợ "1 request đổi nhiều sản phẩm cùng lúc" ở tầng redeem — chị Mai phải gọi 2 lần tách biệt (bước ③ và bước ④), mỗi lần đúng 1 quà, dù cả 2 đều thuộc chung 1 lần Vingroup nạp tiền trước đó (bước ①). "Đổi qua package khác" không có nghĩa "gộp nhiều sản phẩm trong 1 đơn" — nó chỉ đơn giản là 2 hành động đổi quà độc lập, mỗi hành động tự trừ đúng package tương ứng với loại quà đó.

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ế độ ③.

Sổ nhật ký ghi lại 5 dòng cho toàn bộ diễn biến trên:

STTHành động thực tếtypeamountsourcepackageLoại quàbilling_modechildMasterTransactions
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-
card-topup
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-
card-topup
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-
card-topup
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-
card-payment
9 lượt enduser PKG-CAFE-01 Voucher Highlands ②
ON_REDEEM_BY_
QUANTITY
—
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-
card-payment
4 lượt enduser PKG-FOOD-02 Voucher Golden Gate ③
ON_MERCHANT_USE
—
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-
redeem
4 lượt enduser PKG-FOOD-02 Voucher Golden Gate ③
ON_MERCHANT_USE
—
Vì sao cột "Loại quà" quan trọng hơn cột "package": dòng 3 và dòng 4 là 2 hành động đổi quà HOÀN TOÀN ĐỘC LẬP (không cùng 1 lần gọi), tuy thuộc 2 package khác nhau — nhưng điều thật sự quyết định chế độ đối soát là loại quà, không phải package. Highlands snapshot 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.
Đọc bảng này ra sao: điểm quan trọng nhất cần thấy là 2 package không hề liên quan tới nhau — 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.
Vì sao dòng 3 và dòng 4 tách biệt hoàn toàn: hệ thống không hỗ trợ "1 lần gọi đổi nhiều sản phẩm" — mỗi lần đổi quà chỉ được đúng 1 loại quà. Highlands (dòng 3) và Golden Gate (dòng 4) là 2 hành động riêng biệt của chị Mai, xảy ra ở 2 thời điểm khác nhau, mỗi hành động tự trừ đúng package tương ứng và tự snapshot đúng 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 ①).
Vì sao cần dòng 5 (dùng thật tại Merchant): đây là điểm khác biệt lớn nhất giữa chế độ ③ và các chế độ còn lại — với Golden Gate, dòng 4 (đổi quà) đã xảy ra từ trước, nhưng vì loại quà này chọn chế độ ③ thì dòng đó chỉ là "đặt trước", chưa hề chốt tiền. Chỉ có dòng 5 — sinh ra đúng lúc nhân viên Merchant quét mã — mới là dòng duy nhất DA cần lọc để tính công nợ. Điều này giải thích vì sao chế độ ③ an toàn hơn cho client: nếu chị Mai không bao giờ mang mã Golden Gate ra quán (hoặc mã hết hạn), dòng 5 sẽ không bao giờ xuất hiện, và Vingroup không phải trả đồng nào cho phần đó — dù đã "đổi quà" từ trước rồi.

1.4 Mở rộng: 1 lần nạp tiền chia vào nhiều gói lượt độc lập cùng lú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.

Cấu hình quà — 2 bên đã thống nhất những gì trước khi có giao dịch nào xảy ra

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.

Đọc cấu hình này ra sao: mỗi lần Sacombank nạp tiền, hệ thống KHÔNG hỏi "vào gói nào" — nó tự đổ đồng thời 14 lượt vào gói Cà phê và 6 lượt vào gói Trà, theo đúng số đã thống nhất sẵn. Nếu 1 khoản nạp chỉ cần đổ vào đúng 1 gói duy nhất (không chia cho gói nào khác) — như trường hợp gói Cà phê và gói Ẩm thực của Vingroup ở use case 1.3 — hệ thống cũng hỗ trợ kiểu "chỉ định đúng 1 gói", không bắt buộc phải chia nhiều gói cùng lú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}] }.

① Sacombank gọi 1 lần API topup cho gift_detail_config này — hệ thống đọc topup_quantity_config, tự tách thành 2 dòng: 14 lượt vào PKG-FNB-CAFE, 6 lượt vào PKG-FNB-TEA → ② Anh Khoa đổi 6 lượt lấy Voucher Highlands — trừ thẳng từ PKG-FNB-CAFE, không đụng gì tới PKG-FNB-TEA

Sổ nhật ký ghi lại 3 dòng cho toàn bộ diễn biến — không có dòng trung gian nào:

STTHành động thực tếtypeamountsourcepackageLoại quàbilling_modeGhi chú
1 Sacombank topup — hệ thống tự tách theo topup_quantity_config: 14 lượt vào PKG-FNB-CAFE client-catalog-
card-topup
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-
card-topup
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-
card-payment
6 lượt enduser PKG-FNB-CAFE Voucher Highlands ②
ON_REDEEM_BY_
QUANTITY
Dòng đổi quà thật — chỉ dòng này mới tính vào công nợ
Đọc bảng này ra sao: không có bước "chuyển tiếp" hay dòng trung gian nào — khác hẳn 1 mô hình cha/con giả định (nơi lượt phải "chảy" qua từng lớp trước khi tới nơi đổi được quà). Ở đây 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.

Vậy "còn bao nhiêu lượt" ở mỗi package được giữ lại bằng cách nào, để lần đổi quà sau còn biết mà trừ tiếp?

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.

Số lượt hiện có của 1 package = SUM(amount) các dòng topup vào package đó − SUM(amount) các dòng payment/refund trừ ra khỏi package đó

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

Vì sao không cần cột số dư riêng: nếu Sacombank nạp tiếp 1 đợt nữa vào 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.
Không nhầm với 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.

Phần 2 — Cách tính tiền client phải trả

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.

Vì sao không thể nói "campaign này dùng chế độ nào": 1 campaign thường bán nhiều loại quà khác nhau — ví dụ vừa có quà nạp điểm/lượt, vừa có voucher đổi thẳng, có khi còn có cả quà dạng dịch vụ (đặt chỗ, booking). Mỗi loại quà có đặc điểm khác nhau nên thường được cấu hình chốt tiền ở thời điểm khác nhau: quà cần xác nhận qua nhà cung cấp bên thứ 3 (ví dụ đặt phòng, đặt bàn) hợp lý hơn khi chốt tiền ngay lúc client gọi API (chế độ ①, vì khi request đã được nhà cung cấp xác nhận thành công thì coi như giao dịch chắc chắn xảy ra), trong khi voucher ăn uống có thể chờ đến lúc khách hàng thực sự dùng tại cửa hàng mới chốt (chế độ ③). DA khi đối soát phải đọc đúng cột 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.
Lưu ý quan trọng: đây là chuyện khác hoàn toàn với "hạn mức ngân sách" mà UrBox và client thoả thuận trước (gọi là SubPO — số tiền tối đa client cam kết chi trong hợp đồng). Phần này nói về số tiền THỰC TẾ client phải trả tại từng thời điểm cụ thể, dựa trên hành động thật đã xảy ra.
① Chốt full lúc request ② Chốt theo quà con đã đổi ③ Chốt lúc dùng tại cửa hàng ④ Chốt full quà cha khi đổi quà con
1 FULL_ON_REQUEST — Chốt toàn bộ tiền ngay lúc client gọi API
Chốt sớm nhất — không cần chờ khách hàng làm gì

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

Tiền client nợ = SUM(amount) của các dòng có billing_mode = ① (thường rơi vào dòng type = client-*-topup hoặc client-redeem-voucher, nhưng phải lọc theo billing_mode chứ không lọc theo type — 2 dòng cùng type vẫn có thể khác billing_mode nếu khác loại quà)

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.

2 ON_REDEEM_BY_QUANTITY — Chốt theo đúng giá trị quà con khách hàng đã đổi
Chốt theo số lượng thực tế đã dùng, tính ngay lúc đổi — mỗi lần đổi quà là 1 dòng riêng, đúng giá trị quà con đó

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.

Tiền client nợ = SUM(amount) của các dòng có billing_mode = ② (thường rơi vào dòng type = user-*-payment của loại quà đã cấu hình chế độ này — nhưng phải lọc theo billing_mode, không lọc theo type, vì dòng user-*-payment của loại quà KHÁC có thể đang mang billing_mode khác)

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ế độ ③.

3 ON_MERCHANT_USE — Chốt muộn nhất, chỉ khi khách hàng dùng thật tại cửa hàng
An toàn nhất cho client, rủi ro treo công nợ nếu khách không bao giờ dùng

Đâ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.

Tiền client nợ = SUM(amount) của các dòng có billing_mode = ③ (thường rơi vào dòng type = user-merchant-redeem của loại quà đã cấu hình chế độ này, sinh ra đúng lúc cửa hàng quét mã — nhưng vẫn phải lọc theo billing_mode để không lẫn dòng của loại quà khác)

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.

Điểm cần lưu ý khi đối soát: vì chốt muộn nhất, sẽ luôn có 1 số mã đã phát ra nhưng chưa bao giờ được dùng (khách hàng quên, hoặc mã hết hạn). Những trường hợp này không bao giờ phát sinh dòng chốt tiền — tức là client không phải trả cho phần đó. Đây là sự khác biệt lớn nhất so với các chế độ còn lại.
4 FULL_PARENT_ON_FIRST_REDEEM — Chốt đủ giá trị cả bộ (quà cha) ngay khi đổi 1 quà con bất kỳ
Đơn vị chốt là "quà cha" — tức cả package/gift_set, không phải quà con vừa đổi

Đâ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).

Tiền client nợ = SUM(giá trị quà cha ĐÃ CHỐT tại đúng thời điểm phát sinh dòng có billing_mode = ④ ứng với 1 quà con bất kỳ trong bộ đó, tra theo point_price_config/quantity_price_config khớp tại thời điểm đó — không phải giá hiện tại lúc DA chạy báo cáo, và không nhân theo số lượt x)

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

Sổ nhật ký ghi lại đúng cho ví dụ trên:

STTHành động thực tếtypeamountsourceLoại quàbilling_modeSố 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-
card-payment
9 lượt enduser Voucher Highlands ④
FULL_PARENT_ON_
FIRST_REDEEM
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
Điểm dễ nhầm với chế độ ②: chế độ ② tính tiền theo đúng giá trị quà con vừa đổi (số lượng × đơn giá quà con đó). Chế độ ④ hoàn toàn không quan tâm giá trị quà con — chỉ dùng hành động "đổi 1 quà con bất kỳ" như điều kiện kích hoạt, còn số tiền chốt là giá trị quà cha tại đúng thời điểm đó. Nhìn vào dòng 1 ở bảng trên, cột 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 đó).

Phần 3 — Bảng tổng hợp so sánh nhanh

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

Đừng lọc theo 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-*-topup
client-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
Cách đọc bảng này khi có nhiều loại quà trong 1 campaign: mỗi loại quà (mỗi 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.
Tóm 1 câu: luôn hỏi "loại quà này đang cấu hình chế độ tính tiền nào" trước khi viết báo cáo công nợ — KHÔNG hỏi "campaign này dùng chế độ nào", và KHÔNG bao giờ suy billing_mode từ type. 1 campaign có thể trộn nhiều loại quà với nhiều chế độ khác nhau cùng lúc, và ngay cả 2 dòng cùng type cũng có thể khác billing_mode nếu khác loại quà. Với chế độ ①②③, câu trả lời nằm gọn trong 1 câu lệnh 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.