Hóa đơn đăng ký nhiều tiền tệ: cách làm tròn thực tế và mô hình tối thiểu để giữ tổng khớp giữa web, mobile và xuất kế toán.

Một vấn đề thường gặp: checkout trên web hiện một tổng, app mobile hiện tổng hơi khác, và export kế toán cho ra một con số thứ ba. Mỗi hệ thống đều làm toán "hợp lý", nhưng không phải cùng một phép tính.
Đăng ký làm vấn đề tệ hơn vì bạn lặp lại phép tính nhiều lần. Những khác biệt nhỏ tích lũy qua các lần gia hạn, proration khi nâng cấp giữa chu kỳ, credit và refund, phí retry sau thanh toán thất bại, và các khoảng thời gian không tròn ở đầu hoặc cuối gói.
Sự lệch thường bắt nguồn từ những lựa chọn tí hon mà ban đầu không nhìn thấy cho đến lúc nó gây vấn đề: khi nào làm tròn (mỗi dòng hay cuối cùng), cơ sở tính thuế nào dùng (net hay gross), xử lý tiền tệ có đơn vị nhỏ 0 hay 3 chữ số như thế nào, và tỷ giá FX nào được áp dụng (mốc thời gian nào, nguồn nào, độ chính xác ra sao). Nếu web làm tròn 2 chữ số cho mỗi dòng mà mobile chỉ làm tròn tổng cuối cùng, bạn có thể thấy khác 0.01 ngay cả khi đầu vào giống hệt.
Mục tiêu nghe có vẻ nhàm nhưng quan trọng: cùng một hóa đơn phải cho cùng một tổng ở mọi nơi, mọi lúc. Điều đó giữ khách hàng yên tâm, giảm ticket hỗ trợ, và đứng vững khi kiểm toán.
"Nhất quán" nghĩa là với một invoice ID và phiên bản:
Ví dụ: khách hàng nâng từ EUR 19.99 lên EUR 29.99 giữa tháng, bị tính proration, rồi có credit nhỏ vì downtime. Nếu một hệ thống làm tròn mỗi dòng proration mà hệ thống khác chỉ làm tròn tổng cuối, export có thể khác với thứ khách hàng thấy, dù mọi số đơn lẻ trông đều "gần đúng".
Trước khi tranh luận về tỷ giá FX hay quy tắc làm tròn thuế, hãy cố định những điều cơ bản. Nếu chúng lỏng lẻo, hóa đơn sẽ lệch giữa web, mobile và export.
Mỗi dòng hóa đơn và tổng hóa đơn nên rõ ràng mang theo ba số: net (trước thuế), tax, và gross (net + tax). Chọn một cái làm nguồn sự thật để lưu và tính, rồi dẫn xuất các giá trị khác theo cùng một cách ở mọi nơi. Nhiều đội lưu net và tax, rồi tính gross = net + tax vì điều đó thuận tiện cho kiểm toán và hoàn tiền.
Ghi rõ tiền tệ của từng con số. Các đội thường lẫn ba khái niệm khác nhau:
Chúng có thể giống nhau nhưng không nhất thiết. Nếu hóa đơn của bạn bằng EUR nhưng thẻ thanh toán được settle bằng USD, hóa đơn vẫn phải nhất quán trong EUR dù khoản nộp ngân hàng khác.
Tiếp theo, xử lý tiền như số nguyên ở đơn vị nhỏ (ví dụ cents). Lưu 9.99 dưới dạng float là cách phổ biến gây ra lỗi 9.989999 sau này, đặc biệt khi thêm thuế, chiết khấu, proration hoặc nhiều mục. Lưu 999 (cents) kèm mã tiền tệ, và chỉ format khi hiển thị.
Cuối cùng, quyết định chế độ định giá có thuế hay không:
Một kiểm tra cụ thể: một gói hiển thị 10.00 (đã gồm thuế, VAT 20%) nên sinh ra cùng một gross đã lưu ở đơn vị nhỏ trên web và mobile, sau đó dẫn xuất net và tax theo một quy tắc chung.
Khác biệt FX thường bắt đầu trước cả quy tắc thuế và làm tròn. Hai hệ thống có thể đều "đúng" nhưng vẫn không khớp vì dùng nguồn khác nhau, mốc thời gian khác nhau, hoặc độ chính xác khác nhau.
Nhà cung cấp tỷ giá hiếm khi khớp hoàn toàn. Có nơi quote mid-market, có nơi đã bao gồm spread. Một số cập nhật mỗi phút, vài nơi mỗi giờ hoặc mỗi ngày. Dù cùng nhà cung cấp, hệ thống này có thể làm tròn tỷ giá 4 chữ số còn hệ thống kia giữ 8+ chữ số, và điều đó thay đổi tổng khi nhân với tiền đăng ký và thuế.
Quyết định quan trọng nhất là mốc thời gian của tỷ giá nghĩa là gì. Nếu bạn tính phí bằng EUR mà khách thanh toán bằng USD, bạn khóa tỷ giá khi phát hành hóa đơn hay khi capture thanh toán? Cả hai đều phổ biến, nhưng trộn chúng giữa web, mobile và export đảm bảo sẽ mismatch.
Khi đã chọn quy tắc, hãy lưu chính xác tỷ giá bạn dùng trên hóa đơn. Đừng tính lại từ "tỷ giá hiện tại" sau này, dù bạn có thể tra tỷ giá lịch sử. Nhà cung cấp sửa đổi, khác biệt múi giờ, và thay đổi nhỏ về độ chính xác sẽ làm các hóa đơn cũ lệch khi export hoặc tái sinh PDF.
Ví dụ đơn giản: bạn phát hành hóa đơn lúc 23:59, nhưng thanh toán thành công lúc 00:02. Những time-stamp này thường rơi vào "ngày" khác nhau của nhà cung cấp, nên bảng tỷ giá hàng ngày có thể sinh số khác nhau.
Quyết định và ghi lại các chi tiết FX sau:
Các trường hợp đặc biệt cần xử lý sớm: tiền tệ không có chữ số thập phân (như JPY), tỷ giá có độ chính xác rất cao, và các refund. Refund thường nên tái sử dụng tỷ giá đã lưu trên hóa đơn gốc. Nếu không, số tiền refund có thể khác so với kỳ vọng của khách và export kế toán.
Nếu bạn muốn hóa đơn khớp giữa web, mobile và export kế toán, mô hình dữ liệu phải lưu kết quả, không chỉ đầu vào. Mục tiêu đơn giản: cùng một hóa đơn nên render cùng các đơn vị nhỏ ở mọi nơi, thậm chí vài tháng sau.
Một tập nhỏ các thực thể thường đủ:
Quy tắc then chốt: các trường tiền nên là số nguyên ở đơn vị nhỏ. Lưu cả unit price và các tổng dòng đã tính sẵn. Điều đó ngăn không cho tính lại sau này với quy tắc làm tròn khác hoặc nguồn FX khác.
FX cần được chụp trên hóa đơn, không được suy ra. Dù bạn lưu một bảng FX chung, hóa đơn vẫn nên lưu chính xác fx_rate_value đã dùng vào thời điểm finalize (kèm nguồn) để export có thể tái tạo cùng con số.
Bạn chỉ cần bảng tax breakdown riêng khi một hóa đơn có thể có nhiều mức thuế hoặc nhiều khu vực cùng lúc (ví dụ: nhiều mặt hàng khác nhau, VAT EU + lệ phí địa phương, hoặc thay đổi thuế theo địa chỉ trong cùng hóa đơn). Khi đó lưu một hàng cho mỗi mức thuế với taxable_base_minor và tax_amount_minor.
Cuối cùng, coi hóa đơn đã finalize là bất biến. Lưu một snapshot các giá trị đã tính tại thời điểm nó trở thành final, và không bao giờ tính lại tổng từ subscription sau này. Một lựa chọn này loại bỏ hầu hết lỗi "tại sao cents thay đổi?".
Làm tròn không phải là chi tiết toán học. Đó là quy tắc sản phẩm. Nếu web làm tròn một cách, mobile làm khác, và export kế toán lại khác, bạn sẽ có tổng khác ngay cả khi đầu vào giống nhau.
Có ba chiến lược phổ biến, khác nhau ở chỗ bạn "khóa" đơn vị nhỏ:
Với đăng ký, mặc định tốt là làm tròn theo dòng. Nó dễ dự đoán cho khách (mỗi dòng trông hợp lý), dễ kiểm toán (bạn có thể giải thích tổng từng dòng), và ổn định qua các lần gia hạn. Làm tròn theo đơn vị có thể lệch khi số lượng thay đổi hoặc khi bạn hiển thị giá đơn vị trên UI. Làm tròn chỉ trên tổng hóa đơn thường tạo ra ticket "tại sao các dòng cộng không bằng tổng?" vì tổng các dòng hiển thị không khớp với tổng cuối.
Vấn đề đồng xu kinh điển xuất hiện khi bạn có nhiều mục nhỏ hoặc thuế phân số. Ví dụ: 20 dòng mỗi dòng cho ra phần dư làm tròn 0.004. Làm tròn theo dòng có thể tạo ra khác biệt 0.08 so với làm tròn chỉ ở cuối. Với chuyển đổi FX, các phần thừa nhỏ này xuất hiện nhiều hơn và có thể tích lũy theo thời gian trong export và báo cáo doanh thu.
Dù chọn gì, hãy làm cho nó xác định. Cùng đầu vào phải luôn cho cùng đầu ra giữa web, mobile và export:
Nếu bạn xây cả flow web và mobile, viết quy tắc làm tròn thành spec có thể kiểm thử, chứ đừng để nó là hành vi UI.
Để giữ cùng số trên web, mobile và export kế toán, coi phép tính như một công thức. Ý chính: tính với độ chính xác cao, nhưng chỉ lưu và cộng các số nguyên ở tiền tệ hóa đơn.
Bắt đầu với mỗi dòng item net ở độ chính xác cao. Giữ thêm chữ số thập phân khi nhân với quantity, áp dụng chiết khấu, và (nếu cần) chuyển đổi tiền tệ. Rồi làm tròn một lần sang đơn vị nhỏ của hóa đơn theo quy tắc đã chọn. Lưu số nguyên đó làm line net.
Tính thuế từ line net đã lưu (hoặc từ subtotal nhóm thuế nếu quy tắc cho phép gom nhóm). Áp dụng cùng quy tắc làm tròn và lưu tax là số nguyên ở đơn vị nhỏ. Đây là nơi hệ thống thường lệch: bên này làm tròn trước thuế, bên kia làm tròn sau.
Tính gross mỗi dòng = (net đã lưu + tax đã lưu). Tổng hóa đơn là tổng các số nguyên đã lưu. Đừng tính lại tổng từ giá trị float để hiển thị. UI và export nên đọc các số nguyên đã lưu và format chúng.
Nếu luật địa phương yêu cầu tổng thuế ở cấp hóa đơn, bạn có thể cần phân phối phần dư. Ví dụ: ba dòng mỗi dòng 0.01 thuế có thể cộng thành 0.03, nhưng làm tròn ở cấp hóa đơn cho 0.02. Quyết định quy tắc tie-break xác định (ví dụ: cộng hoặc trừ 1 đơn vị nhỏ bắt đầu từ dòng có cơ sở tính thuế lớn nhất, rồi sắp xếp ổn định theo id dòng). Lưu điều chỉnh như một sửa nhỏ thuế trên các dòng bị ảnh hưởng để mọi hệ thống có thể tái tạo.
Khóa hóa đơn. Sau khi làm tròn cuối cùng và phân phối phần dư, coi hóa đơn là bất biến. Nếu giá subscription thay đổi sau đó, tạo hóa đơn mới hoặc ghi chú credit, nhưng đừng rewrite các con số cũ.
Một kiểm tra cụ thể: nếu gói EUR 9.99 có VAT 19%, giá trị lưu có thể là net 999 cents, tax 190 cents, gross 1189 cents. Mọi client nên render 11.89 EUR từ những số nguyên đã lưu đó, không phải bằng cách tính lại VAT trên fly.
Làm tròn thuế là nơi toán học đúng biến thành hóa đơn không khớp. Vấn đề lõi đơn giản: làm tròn sớm thay đổi tổng cuối.
Nếu bạn làm tròn thuế theo từng dòng (hoặc theo số lượng), rồi cộng, bạn có thể có tổng khác với việc cộng thuế chưa làm tròn rồi làm tròn một lần ở cuối. Với nhiều dòng, khoảng cách cộng dồn, nhất là khi đơn vị nhỏ và chuyển đổi FX đã tạo sẵn các phân số.
Ví dụ cụ thể (2 chữ số): hai dòng mỗi dòng có taxable 0.05 với thuế 10%. Thuế chưa làm tròn mỗi dòng là 0.005. Nếu làm tròn theo dòng, mỗi dòng thành 0.01, tổng thuế 0.02. Nếu làm tròn ở cấp hóa đơn, tổng taxable là 0.10, thuế là 0.01. Cả hai đều có thể biện hộ. Chỉ là chúng khác nhau.
Khi bạn phải hiển thị thuế theo dòng nhưng đồng thời cần tổng hóa đơn khớp chính xác, phân bổ phần dư làm tròn một cách xác định:
Export vẫn có thể lệch khi kế toán gom nhóm dòng (theo sản phẩm, theo mức thuế, hay theo khu vực). Để giữ tổng export khớp tổng hóa đơn, phân bổ phần dư trong từng nhóm bắt buộc trước, rồi xác minh tổng nhóm roll-up ra cùng tổng thuế và gross của hóa đơn.
Nếu kế toán yêu cầu tách thuế theo mức hoặc khu vực nhưng UI chỉ hiện một con số, vẫn lưu breakdown (tổng theo mỗi mức/khu vực cộng quy tắc phân bổ dễ kiểm toán). UI có thể hiển thị một tổng duy nhất, trong khi export mang các bucket chi tiết mà không thay đổi grand total.
Hầu hết mismatched invoice xảy ra ở góc cạnh. Quyết các quy tắc sớm và chúng sẽ không còn là bất ngờ.
Tiền tệ không có chữ số thập phân cần xử lý đặc biệt. JPY và KRW không có đơn vị nhỏ, nên mọi bước giả định "cent" sẽ tạo khác biệt lặng lẽ. Quyết xem bạn làm tròn từng dòng, ở cấp thuế, hay chỉ ở tổng cuối, và đảm bảo mọi client dùng cùng cài đặt tiền tệ.
VAT/GST xuyên biên giới có thể thay đổi mức thuế theo vị trí khách và theo bằng chứng bạn chấp nhận (địa chỉ billing, IP, mã thuế). Phức tạp không phải ở mức thuế mà là thời điểm bạn khóa nó. Chọn thời điểm (checkout, ngày phát hành hóa đơn, hoặc ngày bắt đầu dịch vụ) và bám theo.
Proration là nơi phân số nhân lên. Nâng cấp giữa chu kỳ có thể tạo các giá trị như 9.3333... mỗi ngày. Quyết xem bạn prorate net, hay prorate gross, hoặc tính trước theo khoảng thời gian, rồi tính phần còn lại từ đó. Thay đổi thứ tự sẽ thay đổi đơn vị nhỏ cuối cùng.
Ghi lại các quy tắc này để chúng không bị trôi theo thời gian:
Refunds là cái bẫy cuối cùng. Nếu hóa đơn gốc có 0.01 phần dư làm tròn phân bổ một dòng, refund nên đảo ngược phân bổ chính xác đó. Nếu không, khách thấy một tổng, nhưng sổ sách bạn báo một tổng khác.
Phần lớn mismatch không do "toán học khó" mà do những lựa chọn nhỏ không nhất quán ở các phần khác nhau của stack.
Một lỗi lớn là lưu tiền bằng float. Giá như 19.99 không thể biểu diễn chính xác trong nhiều hệ thống, nên lỗi nhỏ tích lũy khi cộng dòng, áp chiết khấu, hay tính thuế. Lưu số ở dạng integer trong đơn vị nhỏ, cộng mã tiền tệ và tỷ lệ đơn vị nhỏ.
Một vấn đề khác là tính lại FX khi export. Khách đã trả theo tỷ giá cụ thể tại thời điểm cụ thể. Nếu export của bạn lấy "tỷ giá hôm nay", bạn có thể kết luận tổng khác dù mọi bước lúc đó đều đúng. Coi hóa đơn là một snapshot: lưu tỷ giá FX đã dùng, các số đã chuyển đổi và kết quả làm tròn.
Khác biệt làm tròn cũng xuất hiện khi UI và backend làm tròn ở các bước khác nhau. Ví dụ, backend có thể làm tròn thuế theo dòng, trong khi web UI chỉ làm tròn ở tổng hóa đơn. Cả hai đều có vẻ hợp lý nhưng không khớp.
Năm lỗi lặp lại giải thích hầu hết khoảng cách:
Một sửa đơn giản nhưng hiệu quả: tính một lần ở backend, lưu snapshot hóa đơn đầy đủ, và để web/mobile render đúng các số đã lưu ấy.
Khi số khác nhau giữa web, mobile và export kế toán, thường không phải do toán học mà do lưu trữ và làm tròn.
Bắt đầu với nguyên tắc: client nên hiển thị những gì hóa đơn lưu, không tính lại. Backend phải là nguồn sự thật duy nhất, và mọi kênh đều đọc cùng các giá trị đã lưu.
Refunds và credit note nên phản chiếu kết quả làm tròn của hóa đơn gốc. Nếu hóa đơn gốc làm tròn thuế theo dòng, refund nên làm giống vậy, dùng cùng độ chính xác tiền tệ và tỷ giá FX đã lưu. Nếu không, các phần dư nhỏ có thể xuất hiện và tích lũy theo thời gian.
Cách thực tế để đảm bảo: lưu một snapshot tính toán rõ ràng kèm mỗi hóa đơn: tiền tệ, độ chính xác đơn vị nhỏ, chế độ làm tròn, tỷ giá FX và timestamp, và các dòng đã hoàn tất.
Dưới đây là một hóa đơn giữ nguyên mọi nơi.
Giả sử hóa đơn phát hành bằng EUR (2 chữ số), VAT 20%, và khách bị charge bằng USD. Backend lưu snapshot FX: 1 EUR = 1.0857 USD.
| Item | Net (EUR) |
|---|---|
| Pro plan (monthly) | 19.99 |
| Extra seats | 10.00 |
| Discount (10% of 29.99, rounded) | -3.00 |
Net total (EUR) = 26.99
VAT 20% (EUR) = 5.40 (vì 26.99 x 0.20 = 5.398, làm tròn thành 5.40)
Gross total (EUR) = 32.39
Bây giờ backend dẫn xuất các tổng tính theo tiền charge từ tổng EUR đã lưu và snapshot FX:
Nếu bạn cũng lưu các giá trị theo dòng bằng USD, thường sẽ có 0.01 chênh khi làm tròn từng dòng đã chuyển đổi rồi cộng lại. Đó là nơi hóa đơn thường lệch.
Làm cho nó xác định: chuyển đổi và làm tròn mỗi dòng, rồi phân phối những cent thừa (dương hoặc âm) theo thứ tự cố định (ví dụ theo line_id tăng dần) cho đến khi tổng theo dòng bằng với gross USD đã cố định.
Web và mobile nên hiển thị các tổng dòng đã lưu từ backend, tổng thuế, tỷ giá FX và gross, chứ không phải tính lại. Export kế toán nên xuất cùng các số đã lưu kèm snapshot FX (tỷ giá, timestamp hoặc nguồn) để sổ sách khớp với những gì khách hàng thấy.
Bước thực tế tiếp theo là triển khai phép tính như một dịch vụ dùng chung xuất ra một snapshot hóa đơn duy nhất (dòng, thuế, tổng, FX, điều chỉnh làm tròn) và để mọi kênh render từ đó. Nếu bạn xây các luồng này trên Koder.ai (koder.ai), mô hình snapshot này giúp web, mobile và export giữ đồng bộ vì chúng đều đọc cùng các giá trị đã lưu.
Bởi vì mỗi hệ thống thường đưa ra những lựa chọn hơi khác nhau về khi nào làm tròn, cái gì được làm tròn (net hay gross), và giữ bao nhiêu chữ số cho thuế và FX. Những khác biệt nhỏ đó dẫn đến chênh 0.01–0.02 EUR/đơn vị, đặc biệt khi có proration, credit và retry lặp lại phép tính theo thời gian.
Lưu số tiền dưới dạng số nguyên ở đơn vị nhỏ (ví dụ cents) cùng mã tiền tệ, và chỉ định dạng cho hiển thị. Số dấu phẩy động không thể biểu diễn nhiều thập phân chính xác nên sẽ sinh lỗi nhỏ khi cộng thuế, chiết khấu hoặc nhiều dòng.
Chọn một giá trị làm nguồn sự thật và dẫn xuất các phần còn lại theo cùng một quy tắc ở mọi nơi. Thông thường lưu net và tax ở đơn vị nhỏ rồi tính gross = net + tax — dễ xử lý hoàn tiền và kiểm toán hơn và giữ tổng ổn định.
Invoice currency là tiền tệ mà hóa đơn được biểu thị và dùng để đối chiếu. Display currency là thứ bạn hiển thị khi người dùng xem giá, còn settlement currency là tiền tệ mà nhà xử lý thanh toán nộp vào tài khoản ngân hàng; chúng có thể khác nhau miễn là phép tính trên invoice currency vẫn nhất quán.
Đừng lấy tỷ giá khi xuất hoặc tái sinh PDF. Lưu chính xác tỷ giá FX đã dùng trên hóa đơn (giá trị, độ chính xác, nhà cung cấp và thời điểm hiệu lực), rồi luôn dùng lại để các hóa đơn cũ tái tạo đúng số liệu sau nhiều tháng.
Khóa một quy tắc duy nhất: hoặc “tỷ giá tại thời điểm phát hành hóa đơn”, hoặc “tỷ giá tại thời điểm capture thanh toán”, rồi áp dụng thống nhất. Trộn hai mốc thời gian khác nhau giữa các hệ thống là nguyên nhân phổ biến gây lệch, nhất là quanh nửa đêm và khác múi giờ.
Mặc định nên làm tròn theo dòng cho hóa đơn đăng ký: dễ giải thích cho khách, dễ kiểm toán (bạn có thể giải thích tổng mỗi dòng), và ổn định qua các lần gia hạn nếu mọi kênh dùng cùng quy tắc.
Chọn rõ ràng giữa làm tròn thuế theo từng dòng hay theo tổng hóa đơn rồi làm cho nó có thể lặp lại. Nếu cần khớp với mục tiêu tổng hóa đơn, phân phối phần thừa làm tròn một cách cố định và lưu các giá trị thuế sau khi phân bổ để mọi hệ thống tái hiện identically.
Proration sinh ra thập phân lặp (ví dụ tiền theo ngày), nên thứ tự phép toán quyết định kết quả. Chọn một phương pháp (ví dụ: prorate net trước, rồi tính thuế từ net đã lưu), làm tròn ở bước đã thống nhất, và lưu kết quả dòng đã hoàn tất để upgrade/downgrade/credit/refund phản chiếu chính xác toán học ban đầu.
Backend tạo một snapshot hóa đơn đã hoàn tất (dòng, thuế, tổng, quy tắc đơn vị nhỏ, snapshot FX, chế độ làm tròn) và coi đó là bất biến sau khi finalize. Web, mobile, PDF và export đều render từ các số nguyên đã lưu thay vì tính lại; đây cũng là mô hình tốt khi xây luồng thanh toán trên Koder.ai.