← Vibe coding với Next.js

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

Nhờ Claude: trên trang sản phẩm, hiện giá đã gồm VAT kèm nhãn nhỏ "đã bao gồm VAT" dưới mỗi giá. Xong khi mỗi sản phẩm hiện một giá duy nhất (đã gồm VAT) + chú thích, không còn chỗ nào hiện giá chưa thuế cho khách lẻ.

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í

Chỉ khi phí trở thành một khoản phần trăm đáng kể theo đơn - điển hình là dùng cổng THẺ (VNPay/Stripe) thu 1-3% mỗi giao dịch. Lúc đó cộng thẳng sẽ sai, phải dùng công thức gross-up ở bước kế. Còn với SePay phí cố định, gánh hộ khách là lựa chọn nhẹ đầu nhất.

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

Nhiều người tính phi = base × 2% = 3.960 rồi tong = 201.960. Nhưng cổng cắt 2% của 201.960 = 4.039 → shop chỉ nhận 197.921, hụt ~79đ mỗi đơn. Quên mất phí tính trên tổng đã gồm phí. Công thức chia cho (1 − f) mới đúng.
  • 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:

Tiền hàng (đã gồm VAT)220.000đGiảm giá (MEOW20)−22.000đTỔNG THANH TOÁN198.000đtrong đó: giá chưa thuế 180.000đ + VAT 18.000đ (phí SePay shop tự chịu)
Bảng checkout (B2C): giá đã gồm VAT trừ giảm giá ra tổng thanh toán; bên trong tổng gồm giá chưa thuế + VAT; phí SePay shop tự chịu. QR SePay điền sẵn đúng số tổng.

Bảo mật: server tự tính lại

Server phải tự tính lại tổng từ giá gốc và khuyến mãi, tuyệt đối không tin số tiền trình duyệt gửi lên (chống sửa giá ở client). Mã QR mang sẵn số tiền = tổngnội dung = mã tham chiếu đơn - đỡ ca "nạp sai nội dung → tiền treo" ở bài Nạp tiền QR qua SePay.

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

Nhờ Claude dựng bảng tổng kết checkout: tiền hàng → giảm giá → tổng thanh toán (kèm dòng nhỏ "trong đó VAT…"), và tính tổng Ở SERVER. Xong khi đổi giá ở client (sửa bằng dev tools) không làm thay đổi số tiền thực trừ - server vẫn ra đúng tổng.

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

Giá, thuế suất có thể đổi sau này. Lưu snapshot bất biến tại thời điểm mua (đơn giá, thuế suất, voucher của lúc đó) rồi không sửa nữa. Hoá đơn cũ phải luôn dựng lại đúng con số lúc khách mua, không phải con số hôm nay.

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

Nhờ Claude dựng trang hoá đơn ghi đủ các dòng trên + lưu snapshot bất biến, rồi cho subagent bảo mật/data-integrity rà phần tính tiền. 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 không làm đổi con số trên hoá đơn cũ.

Bước tiếp theo

Tiền đã đúng tới từng đồng và có hoá đơn minh bạch. Giờ mèo con cần một chỗ để quản lý hàng, kho, giao dịch và số dư - và phân quyền để admin tách khỏi khách thường: Trang quản trị & phân quyền Casbin.

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

KHÔNG. Tiền luôn lưu bằng số nguyên đồng (như đã chốt từ bài Lưu dữ liệu với PostgreSQL). Float làm tròn sai (0,1 + 0,2 ≠ 0,3 trong máy tính), cộng dồn nhiều đơn là lệch tiền. Tính phần trăm/VAT thì nhân chia trên số nguyên rồi làm tròn về đồng theo quy tắc rõ ràng.