Bài 14 · Nâng cao · 22 phút
Giá bán, VAT & phí giao dịch
Biên soạn bởi Nguyễn Anh Tuấn
Niêm yết giá đã gồm VAT (kèm chú thích "đã bao gồm VAT") cho khách lẻ, và cho khách trả thêm phí giao dịch một cách minh bạch. Tách bạch các lớp tiền theo đúng thứ tự: tiền hàng - giảm giá - giá tính thuế - VAT 10% - phí - tổng. Bài toán kinh điển gross-up khi phí là phần trăm (tổng = base / (1 − phí%)) và cạm bẫy phí chồng phí; làm tròn số nguyên đồng để shop không hụt. Màn giỏ hàng, checkout và hoá đơn ghi rõ từng loại tiền, thuế và phí để khách xem lại được.
Khách bấm "Thanh toán" và thấy một con số. Nhưng đằng sau con số đó là vài lớp tiền khác nhau: tiền hàng, thuế VAT, và một khoản phí giao dịch mà nhà cung cấp thu của shop. Trộn lẫn các lớp này là nguồn của đủ thứ rắc rối: tính sai tiền shop nhận, không đối soát được, và tệ nhất là bị khách tố "thu mập mờ".
Bài này tách bạch các lớp đó cho đúng, theo lựa chọn đã chốt của khoá: với khách lẻ, niêm yết giá đã gồm VAT (kèm chú thích "đã bao gồm VAT"), và shop tự chịu phí giao dịch để khách chỉ thấy một giá sạch, không có dòng phí lạ. Đây nối tiếp ngay sau bài Hàng đợi BullMQ & khoá số dư: mua hàng đã trừ số dư an toàn, giờ lo cho con số bị trừ là đúng tới từng đồng.
Quyết định quan trọng nhất là thứ tự tính - chốt một lần rồi bám mãi. Tất cả đều là số nguyên đồng (không float, như đã chốt ở bài Lưu dữ liệu với PostgreSQL):
thứ tự tính (đơn vị: đồng) - giá hiển thị ĐÃ GỒM VAT, shop tự chịu phí
tiền hàng = Σ (đơn giá hiển thị × số lượng) # đã gồm VAT
giảm giá = voucher / khuyến mãi
TỔNG khách trả = tiền hàng − giảm giá # đã gồm VAT, KHÔNG đội phí
↳ tiền VAT = làm tròn(TỔNG / 11) # phần VAT nằm trong giá
↳ giá chưa thuế = TỔNG − tiền VAT # suy ra để LUÔN khớp
# Phí SePay là chi phí cố định của shop, tính vào giá vốn - không cộng vào đơn. - ▸Thuế luôn tính trên giá SAU khuyến mãi, không phải giá gốc.
- ▸Giá đã gồm VAT thì tiền VAT = giá / 11 (vì 110% ứng với giá, 10% là VAT → VAT = giá × 10/110).
- ▸Luôn suy giá-chưa-thuế = tổng − tiền VAT (không tính riêng), để chưa-thuế + VAT khớp tổng từng đồng.
- ▸Phí giao dịch (SePay) là chi phí vận hành của shop - nằm trong giá vốn, không phải một dòng khách trả.
Trên màn hình sản phẩm, hiện giá đã gồm VAT kèm một chú thích nhỏ "đã bao gồm VAT". Khách thấy đúng con số họ sẽ trả, không bị đội lên ở phút chót. Ví dụ một combo hiển thị 110.000đ - bên trong là 100.000đ tiền hàng + 10.000đ VAT.
tách ngược VAT từ giá đã gồm thuế (số nguyên đồng)
gia_hien_thi = 110000 # đã gồm VAT
tien_vat = round(gia_hien_thi / 11) # 10.000đ
gia_chua_thue = gia_hien_thi - tien_vat # 100.000đ
# 100.000 + 10.000 = 110.000 ✓ khớp gõ trong ô Claude Code
Trên trang sản phẩm (route app/(shop)/san-pham/[slug]/page.tsx) và trên các thẻ sản phẩm ở trang danh sách, hiển thị giá đã gồm VAT cho khách lẻ.
Yêu cầu:
- Cột giá trong schema Drizzle là số nguyên đồng (integer), tên gia_da_gom_vat. Đây là giá khách thấy và sẽ trả.
- Render giá định dạng tiền Việt (vd 110.000đ), ngay dưới mỗi giá thêm một nhãn nhỏ chữ xám "đã bao gồm VAT".
- Viết một hàm format ở lib/money.ts để dùng chung mọi nơi (trang sản phẩm, danh sách, giỏ hàng).
- Tuyệt đối không hiển thị giá chưa thuế cho khách lẻ ở bất kỳ màn nào.
Xong khi: mỗi sản phẩm chỉ hiện một con số duy nhất (giá đã gồm VAT) kèm chú thích "đã bao gồm VAT", grep toàn bộ phần khách lẻ không còn chỗ nào render giá chưa thuế. Tới lượt mèo con
Trước khi quyết ai gánh phí, phải biết phí thực ra là gì. Theo bảng giá, SePay tính phí theo gói thuê bao tháng (theo số lượng giao dịch), cộng phí hoá đơn điện tử 100-600đ/hoá đơn - không cắt phần trăm trên mỗi đơn. Bản thân cú chuyển khoản 24/7 ở Việt Nam gần như miễn phí cho khách.
Vì đây là chi phí cố định theo tháng, không phải khoản phát sinh theo từng đơn, khoá chọn cách gọn và đẹp cho khách lẻ: shop tự chịu, coi nó như tiền điện hay mặt bằng - một chi phí vận hành, tính sẵn vào giá vốn khi đặt giá bán. Khách chỉ thấy một giá sạch đã gồm VAT.
- ▸SePay: chi phí cố định/bán-cố-định của shop (thuê bao tháng + phí mỗi hoá đơn), KHÔNG phải % theo đơn.
- ▸Khoá chọn shop tự chịu: tính phí vào giá vốn lúc định giá, không hiện dòng phí lạ cho khách.
- ▸Tổng khách trả = tiền hàng (đã gồm VAT) − giảm giá. Không cộng thêm phí.
Khi nào mới nên cho khách gánh phí
Phần này dành cho lúc mèo con đổi sang cổng thẻ thu phí theo % và muốn khách gánh phí. Đây là bài toán kinh điển: phí tính theo phần trăm trên tổng tiền khách trả, mà tổng lại gồm cả phí - phí chồng lên chính nó. Giải bằng công thức gross-up:
gross-up - khách gánh phí % mà shop vẫn nhận đủ
base = 198000 # số shop muốn NHẬN ĐỦ (giá đã gồm VAT, sau giảm)
f = 0.02 # phí cổng thẻ 2%
tong = ceil(base / (1 - f)) # ceil(198000 / 0.98) = 202.041đ
phi = tong - base # 4.041đ
# Kiểm: cổng cắt f × tong = 0.02 × 202.041 ≈ 4.041 → shop nhận
# tong − phi = 198.000 ✓ đúng Cạm bẫy hay gặp
- ▸Luôn làm tròn LÊN (ceil) tổng/phí để shop không bao giờ hụt; phần lẻ vài chục đồng do làm tròn thì khách chịu.
- ▸Khi có thu phí khách: hiện rõ một dòng "phí giao dịch" ở checkout và trên hoá đơn - đừng nhồi vào giá.
- ▸Bất biến phải đúng: sau khi cổng trừ phí, số shop thực nhận ≥ số shop muốn nhận.
Ở giỏ hàng, hiện giá đã gồm VAT và một tạm tính. Ở checkout, bày ra bảng minh bạch: tiền hàng, giảm giá, và tổng thanh toán - vì shop tự chịu phí nên tổng bằng đúng tạm tính, không đội thêm:
Bảo mật: server tự tính lại
gõ trong ô Claude Code
Dựng bảng tổng kết checkout cho trang thanh toán, và tính tổng Ở SERVER, không tin số tiền client gửi lên.
Yêu cầu:
- Viết một hàm thuần ở lib/checkout.ts tên tinhTong(items, voucher) nhận danh sách order_items và voucher, trả về một object gồm: tien_hang, giam_gia, tong_thanh_toan, tien_vat (làm tròn tong_thanh_toan / 11), gia_chua_thue (= tong_thanh_toan trừ tien_vat). Tất cả là số nguyên đồng, không dùng float.
- Thứ tự tính: tien_hang = tổng đơn giá đã gồm VAT nhân số lượng; tong_thanh_toan = tien_hang trừ giam_gia. Không cộng thêm phí giao dịch (shop tự chịu phí SePay).
- Trong Server Action hoặc route handler tạo đơn, GỌI LẠI tinhTong từ giá gốc lấy trong database theo product id, bỏ qua mọi số tiền do client gửi.
- UI bảng checkout hiển thị 3 dòng: Tiền hàng, Giảm giá, Tổng thanh toán; dưới Tổng thêm một dòng nhỏ chữ xám "trong đó VAT: X đồng".
- Viết một test (vitest) cho tinhTong: một giỏ mẫu ra đúng các con số, và gia_chua_thue cộng tien_vat bằng tong_thanh_toan.
Xong khi: sửa giá ở client bằng dev tools rồi bấm thanh toán, số tiền server thực trừ vẫn là tổng tính lại từ database, không đổi theo client. Tới lượt mèo con
Khách phải xem lại được mình đã trả những gì. Hoá đơn ghi rõ từng loại tiền: mỗi dòng hàng (tên, số lượng, đơn giá đã gồm VAT, thành tiền), rồi cộng tiền hàng → giảm giá → giá chưa thuế + tiền VAT (10%) → tổng cộng (bằng số và bằng chữ), kèm mã đơn, thời gian, phương thức. (Nếu có thu phí khách - cổng thẻ - thì thêm một dòng "phí giao dịch".)
schema gợi ý - tách từng cột tiền (tất cả số nguyên đồng)
orders(id, tien_hang, giam_gia,
gia_chua_thue, thue_suat, tien_thue,
loai_phi, tien_phi, -- = 0 khi shop tự chịu phí
tong_thanh_toan, ...)
order_items(id, order_id, ten, so_luong,
don_gia_da_gom_vat, thanh_tien) Lưu snapshot, đừng tính lại từ giá hiện tại
Bất biến để test (đúng tinh thần đối soát ở bài Metrics, audit log & báo cáo):
bất biến - test để chắc không lệch một đồng
tong_thanh_toan == tien_hang − giam_gia + tien_phi # tien_phi = 0 khi shop chịu
gia_chua_thue + tien_thue == tien_hang − giam_gia # tách thuế luôn khớp
shop_thuc_nhan >= tien_hang − giam_gia # shop không bao giờ hụt
mọi cột tiền là số nguyên đồng (không float) gõ trong ô Claude Code
Dựng trang hoá đơn (route app/(shop)/don-hang/[id]/page.tsx) ghi chi tiết từng loại tiền, và LƯU SNAPSHOT BẤT BIẾN khi tạo đơn.
Schema Drizzle (số nguyên đồng):
- Bảng orders thêm/đảm bảo các cột: tien_hang, giam_gia, gia_chua_thue, thue_suat, tien_thue, loai_phi, tien_phi (= 0 khi shop tự chịu), tong_thanh_toan.
- Bảng order_items có: order_id, ten, so_luong, don_gia_da_gom_vat, thanh_tien.
Lưu snapshot:
- Khi tạo đơn, COPY đơn giá và thuế suất tại thời điểm mua vào order_items và orders, KHÔNG tham chiếu sống tới bảng product. Sau đó không sửa các cột này nữa.
- Trang hoá đơn dựng số liệu CHỈ từ các cột đã lưu trong orders/order_items, tuyệt đối không đọc giá hiện tại của product.
UI hoá đơn hiển thị: từng dòng hàng (tên, số lượng, đơn giá đã gồm VAT, thành tiền), rồi tien_hang, giam_gia, gia_chua_thue, tien_thue (VAT 10%), tong_thanh_toan (số và chữ), mã đơn, thời gian, phương thức.
Bất biến + test (vitest):
- tong_thanh_toan == tien_hang trừ giam_gia cộng tien_phi
- gia_chua_thue cộng tien_thue == tien_hang trừ giam_gia
- mọi cột tiền là số nguyên đồng.
Sau khi xong, cho subagent bảo mật/data-integrity rà lại phần tính tiền và snapshot.
Xong khi: mở lại một đơn cũ thấy đủ giá chưa thuế / VAT / tổng khớp từng đồng, và đổi giá sản phẩm ở hiện tại (sửa cột product) không làm đổi bất kỳ con số nào trên hoá đơn cũ. Tới lượt mèo con
Bước tiếp theo
Câu hỏi thường gặp
Vì khách lẻ (B2C) quen thấy giá cuối cùng và ghét cảm giác "phí ẩn" khi tới bước thanh toán mới đội giá. Quy định niêm yết giá ở Việt Nam cũng nghiêng về giá đã gồm VAT. Cách B2B (hiện giá chưa thuế rồi cộng VAT ở cuối) hợp khách doanh nghiệp hơn. Khoá chọn B2C: hiện giá đã gồm VAT + chú thích "đã bao gồm VAT", và tổng thanh toán bằng đúng giá đó.
Vì phí SePay là chi phí CỐ ĐỊNH theo tháng (thuê bao + phí hoá đơn), không phải phần trăm theo từng đơn. Đội một con số lẻ vào mỗi đơn để "thu hộ" một khoản cố định vừa khó giải thích vừa làm khách lẻ khó chịu. Hợp lý hơn là coi nó như tiền điện, mặt bằng - một chi phí vận hành, tính sẵn vào giá vốn khi đặt giá bán. Khách chỉ thấy một giá sạch đã gồm VAT.
Không. Theo bảng giá, SePay tính phí theo GÓI THUÊ BAO THÁNG/số lượng giao dịch (Free 50 GD/tháng, Startup 120.000đ/tháng, Shop 99.000đ/tháng không giới hạn GD), cộng phí hoá đơn điện tử 100-600đ/hoá đơn. Bản thân cú chuyển khoản 24/7 gần như miễn phí cho khách. Nên với SePay, "phí giao dịch" là chi phí cố định/bán-cố-định của shop, không phải phần trăm theo đơn như cổng thẻ.
Gross-up là bài toán: khi phí tính theo PHẦN TRĂM trên tổng tiền khách trả, mà tổng lại gồm cả phí, nên phí "chồng lên chính nó". Công thức đúng: tổng = số-shop-muốn-nhận / (1 − tỉ_lệ_phí). Chỉ cần khi dùng cổng THẺ (VNPay/Stripe/Napas card) thu phí theo % VÀ bạn muốn khách gánh phí đó. Với SePay phí cố định + shop tự chịu thì không gặp bài toán này.
Vì giá bán, thuế suất có thể đổi sau này. Nếu tính lại hoá đơn cũ từ giá HIỆN TẠI thì con số sẽ sai so với lúc khách mua. Cách đúng: lưu snapshot - đơn giá, thuế suất, voucher tại đúng thời điểm mua - rồi không sửa. Đây là một mặt của data integrity, đúng tinh thần "tiền sai một ly đi một dặm" của khoá.