Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 27 — Money & Currency Data Modeling

Data Modeling and ERD Mastery Bài 27/60

Mở đầu — vì sao bài này quan trọng

Trong tất cả các loại dữ liệu mà một Business Analyst phải mô hình hóa, tiền tệ (money) là loại dữ liệu không cho phép sai sót. Sai một chữ tên khách hàng thì khó chịu nhưng còn sửa được. Sai một đồng tiền — lệch số dư tài khoản, sai báo cáo doanh thu, tính nhầm hoa hồng — thì hậu quả có thể là kiện tụng, mất uy tín, hoặc bị thanh tra tài chính.

Điều trớ trêu là phần lớn các lỗi nghiêm trọng về tiền lại bắt nguồn từ một quyết định rất nhỏ ở giai đoạn thiết kế mô hình dữ liệu: chọn sai kiểu dữ liệu cho cột số tiền. Rất nhiều lập trình viên và cả BA non kinh nghiệm vô tư dùng FLOAT hoặc DOUBLE vì nghĩ "tiền cũng là số thập phân thôi mà". Đó là một cái bẫy kinh điển, và nó không hiện ra ngay lúc test — nó âm thầm tích lũy sai số rồi bùng nổ khi hệ thống đã chạy thật với hàng triệu giao dịch.

Bài này tập trung vào ba câu hỏi cốt lõi khi mô hình hóa tiền tệ: (1) Dùng kiểu dữ liệu nào để lưu số tiền cho chính xác tuyệt đối? (2) Làm sao mô hình hóa đa tiền tệ (multi-currency) — VND, USD, EUR — trong cùng một hệ thống? (3) Lưu giá trị quy đổi và tỷ giá (exchange rate) ra sao để báo cáo không bao giờ "trôi"? Đây là kiến thức nền tảng mà bất kỳ ai làm hệ thống có dính tới tiền — ngân hàng, ví điện tử, sàn thương mại điện tử, ERP — đều phải nắm vững.

Khái niệm cốt lõi

Vì sao tuyệt đối KHÔNG dùng FLOAT/DOUBLE cho tiền

Máy tính lưu số thực dấu phẩy động (floating point) theo chuẩn IEEE 754 dưới dạng nhị phân. Vấn đề là nhiều số thập phân quen thuộc với con người không thể biểu diễn chính xác trong hệ nhị phân, giống như 1/3 không viết hết được trong hệ thập phân (0.3333...).

Ví dụ kinh điển:

-- SAI: dùng floating point cho tiền
amount FLOAT  -- 0.1 + 0.2 cho ra 0.30000000000000004, KHÔNG bằng 0.3

Bạn có thể tự kiểm chứng trong bất kỳ ngôn ngữ nào: 0.1 + 0.2 == 0.3 trả về false. Sai số chỉ ở chữ số thứ 17, nghe có vẻ vô hại. Nhưng khi cộng dồn hàng triệu giao dịch, làm tròn nhiều lần, nhân với tỷ giá rồi chia lại, sai số tích lũy đủ lớn để báo cáo doanh thu lệch vài nghìn đồng — và kế toán sẽ không bao giờ khớp được sổ.

Giải pháp là dùng kiểu số thập phân chính xác cố định (fixed-point decimal):

-- ĐÚNG: dùng DECIMAL (hay NUMERIC, tùy DBMS)
amount DECIMAL(19, 4)

DECIMAL lưu số dưới dạng thập phân chính xác từng chữ số, không có sai số nhị phân. 0.1 + 0.2 luôn cho đúng 0.3.

Đọc hiểu DECIMAL(precision, scale)

DECIMAL(p, s) có hai tham số:

  • precision (p): tổng số chữ số có nghĩa (cả phần nguyên lẫn phần thập phân).
  • scale (s): số chữ số sau dấu phẩy.
Ví dụ DECIMAL(19, 4) cho phép tối đa 15 chữ số phần nguyên và 4 chữ số phần thập phân — lưu được tới khoảng 999 nghìn tỷ với độ chính xác 4 số lẻ. Vì sao chọn 4 chữ số thập phân thay vì 2? Vì trong quá trình tính toán trung gian (tỷ giá, lãi suất, chia tỷ lệ) ta cần thêm độ chính xác trước khi làm tròn về 2 số ở bước cuối. Đây là lý do nhiều hệ thống tài chính chuẩn dùng scale 4 (thậm chí 6) cho các cột tính toán, và scale phù hợp với đơn vị tiền cho cột hiển thị cuối cùng.

Vấn đề "đơn vị nhỏ nhất" của từng loại tiền

Mỗi loại tiền có số chữ số thập phân khác nhau, gọi là minor unit / currency exponent (theo chuẩn ISO 4217):

  • VND (đồng Việt Nam): 0 chữ số thập phân. Không có "xu", đơn vị nhỏ nhất là 1 đồng.
  • USD, EUR: 2 chữ số thập phân (cent).
  • JPY (yên Nhật): 0 chữ số thập phân.
  • BHD (dinar Bahrain), KWD (dinar Kuwait): 3 chữ số thập phân.
Điều này nghĩa là không thể dùng một quy tắc làm tròn cứng cho mọi loại tiền. Nếu hệ thống của bạn xử lý cả VND lẫn USD, cách làm tròn và hiển thị phải tra theo bảng cấu hình của từng currency, chứ không hardcode "luôn 2 số lẻ".

Cách lưu currency code

Luôn lưu mã tiền tệ chuẩn ISO 4217 dạng 3 ký tự chữ in hoa: VND, USD, EUR, JPY, SGD. Đừng lưu ký hiệu ($, ) vì ký hiệu nhập nhằng — $ có thể là USD, SGD, AUD, hay HKD. Mô hình tối thiểu cho một khoản tiền gồm hai cột đi liền nhau: số tiền và mã tiền tệ.

price_amount   DECIMAL(19, 4) NOT NULL,
price_currency CHAR(3)        NOT NULL  -- 'VND', 'USD'...

Một quy tắc vàng: một con số tiền không bao giờ có ý nghĩa nếu thiếu currency đi kèm. "100" là 100 gì? 100 VND (bằng ly trà đá) khác hẳn 100 USD. Trong mô hình dữ liệu, hãy coi (amount, currency) là một cặp bất khả phân.

Pattern lưu số nguyên đơn vị nhỏ nhất (minor units)

Một kỹ thuật phổ biến ở các hệ thống fintech (Stripe, PayPal dùng) là lưu tiền dưới dạng số nguyên của đơn vị nhỏ nhất thay vì số thập phân. Ví dụ 10.50 USD lưu thành 1050 (cent), kiểu BIGINT. Cách này loại bỏ hoàn toàn rủi ro thập phân và rất nhanh khi tính toán. Nhược điểm: phải luôn nhớ chia/nhân với hệ số minor unit khi đọc/ghi, và phải biết exponent của từng currency. Với BA, điều quan trọng là biết cả hai pattern tồn tạiDECIMAL cho dễ đọc, BIGINT minor units cho fintech tốc độ cao — và ghi rõ trong data dictionary mình đang dùng pattern nào.

Mô hình hóa tỷ giá (exchange rate) và snapshot giá trị quy đổi

Tỷ giá thay đổi từng giây. Nguyên tắc cốt lõi: một giao dịch đã hoàn tất thì giá trị quy đổi của nó phải được "đóng băng" (snapshot) tại thời điểm phát sinh, không bao giờ tính lại theo tỷ giá hiện tại.

Nếu khách thanh toán 100 USD vào ngày tỷ giá 25.000, doanh thu ghi nhận là 2.500.000 VND — và con số đó phải cố định mãi mãi, dù mai tỷ giá lên 26.000. Vì vậy với mỗi giao dịch đa tiền tệ, bạn thường lưu: số tiền gốc + currency gốc, tỷ giá áp dụng, số tiền đã quy đổi + currency quy đổi.

Tình huống thực tế

Ví dụ 1 — Ví điện tử "MoMo-style" và sai số FLOAT âm thầm

Một startup ví điện tử ở TP.HCM (gọi là PayViet) thiết kế bảng wallet_transaction với cột số dư balance FLOAT. Trong giai đoạn thử nghiệm với vài trăm giao dịch, mọi thứ ổn. Sau 6 tháng vận hành với ~4 triệu giao dịch nạp/rút/chuyển khoản, đội kế toán phát hiện tổng số dư toàn hệ thống lệch khoảng 38.000 VND so với sổ cái ngân hàng đối ứng.

Nguyên nhân: mỗi lần chia tiền (ví dụ chia phí giao dịch theo tỷ lệ), FLOAT tạo ra sai số ở chữ số thứ 15–16, và sau hàng triệu phép cộng dồn, sai số phình to. 38.000 đồng nghe nhỏ, nhưng với một tổ chức tài chính thì số dư hệ thống không khớp ngân hàng là lỗi nghiêm trọng — không thể giải trình với thanh tra.

Bài học: Họ phải migrate toàn bộ cột tiền sang DECIMAL(19, 4) (xem Bài 30 và 44 về kỹ thuật migration), và bổ sung ràng buộc làm tròn về 0 số lẻ cho VND. Đáng lẽ chỉ cần chọn đúng kiểu dữ liệu ngay từ đầu thì đã không có vụ này.

Ví dụ 2 — Sàn TMĐT đa quốc gia và bài toán snapshot tỷ giá

Một sàn thương mại điện tử Đông Nam Á (giả định tên ShopSEA) bán hàng cho khách ở Việt Nam, Singapore, Thái Lan. Ban đầu họ chỉ lưu order_total DECIMAL(19,2) mà không lưu currency, ngầm định "tất cả là VND". Khi mở rộng sang Singapore, một đơn 200 bị hiểu nhầm: hệ thống tưởng 200 VND trong khi khách trả 200 SGD (~3,7 triệu VND). Báo cáo doanh thu sai lệch nghiêm trọng.

Sau khi sửa, họ thiết kế lại:

CREATE TABLE orders (
  order_id          BIGINT PRIMARY KEY,
  amount            DECIMAL(19, 4) NOT NULL,
  currency          CHAR(3)        NOT NULL,        -- tiền khách thực trả
  exchange_rate     DECIMAL(18, 8) NOT NULL,        -- tỷ giá -> VND tại thời điểm đặt
  amount_base_vnd   DECIMAL(19, 4) NOT NULL,        -- snapshot quy đổi, ĐÓNG BĂNG
  rate_captured_at  DATETIME       NOT NULL
);

Nhờ cột amount_base_vnd được snapshot, báo cáo doanh thu hợp nhất luôn ổn định dù tỷ giá biến động hằng ngày. Khi cần đối chiếu, họ vẫn còn amount gốc + exchange_rate để truy vết.

Bài học: (amount, currency) phải đi cặp; và với báo cáo hợp nhất, hãy snapshot giá trị quy đổi tại thời điểm giao dịch thay vì tính động.

Ví dụ 3 — Ngân hàng và bài toán tiền lãi với scale phụ trợ

Một ngân hàng VN tính lãi tiết kiệm hàng ngày. Lãi một ngày của sổ 50 triệu, lãi suất 5%/năm, ra con số lẻ: 50.000.000 × 0.05 / 365 = 6.849,315... đồng. Nếu làm tròn về 0 số lẻ (VND) ngay mỗi ngày rồi cộng dồn 365 lần, kết quả cuối năm lệch đáng kể so với tính một lần.

Giải pháp: cột tích lũy lãi dùng DECIMAL(19, 6) để giữ phần thập phân trong suốt quá trình tích lũy, chỉ làm tròn về đồng nguyên ở bước chi trả cuối cùng. Đây là minh họa cho nguyên tắc "scale phụ trợ cho tính toán trung gian, scale hiển thị cho giá trị cuối".

Bài học: Quyết định làm tròn ở đâu, mấy lần là một quyết định mô hình dữ liệu, không phải chuyện nhỏ của lập trình. Làm tròn sớm và nhiều lần là nguồn gốc của sai lệch tài chính.

Hướng dẫn từng bước

Khi mô hình hóa bất kỳ thuộc tính tiền tệ nào, hãy đi theo quy trình sau:

  • Xác định cột này có cần lưu currency không. Nếu hệ thống chỉ có VND và chắc chắn mãi mãi đơn tiền tệ, có thể bỏ qua currency — nhưng hãy ghi rõ giả định này trong data dictionary. Nếu có bất kỳ khả năng đa tiền tệ, luôn thêm cột currency đi kèm.
  • Chọn kiểu dữ liệu: luôn DECIMAL/NUMERIC, không bao giờ FLOAT/DOUBLE/REAL. Nếu là fintech ưu tiên tốc độ, cân nhắc pattern BIGINT minor units và ghi rõ trong tài liệu.
  • Chọn precision và scale. Quy tắc thực dụng:
- Cột hiển thị/giao dịch cuối: scale theo currency (VND = 0, USD = 2). Nhiều hệ thống dùng chung DECIMAL(19, 4) cho an toàn rồi làm tròn khi hiển thị. - Cột tính toán trung gian (lãi, tỷ giá, phân bổ): tăng scale lên 6–8. - Precision đủ lớn cho con số lớn nhất có thể (với VND, một bất động sản chục tỷ cần ~12 chữ số phần nguyên → DECIMAL(19,4) thừa sức).

  • Thêm ràng buộc (constraint). Ví dụ CHECK (amount >= 0) nếu không cho phép âm, NOT NULL cho cả amount và currency, và khóa ngoại currency tới bảng danh mục tiền tệ.
  • Thiết kế xử lý đa tiền tệ. Nếu cần báo cáo hợp nhất: thêm exchange_rate, amount_base, rate_captured_at. Quyết định base currency của hệ thống (thường VND ở thị trường VN).
  • Tạo bảng danh mục currency. Lưu mã ISO, tên, ký hiệu, và số chữ số thập phân (minor unit) để biết cách làm tròn/hiển thị từng loại.
CREATE TABLE currency (
  code          CHAR(3)     PRIMARY KEY,   -- 'VND', 'USD'
  name          VARCHAR(50) NOT NULL,
  symbol        VARCHAR(5)  NOT NULL,      -- '₫', '$'
  decimal_digits TINYINT    NOT NULL       -- VND=0, USD=2
);
  • Ghi vào data dictionary (xem Bài 17): kiểu dữ liệu, scale, currency, quy tắc làm tròn, và pattern lưu trữ. Đây là tài liệu giúp cả đội thống nhất, tránh mỗi người hiểu một kiểu.

Lỗi thường gặp & mẹo

  • Dùng FLOAT/DOUBLE cho tiền. Lỗi nghiêm trọng nhất và phổ biến nhất. Luôn dùng DECIMAL/NUMERIC.
  • Quên cột currency. "Số tiền không currency" là một con số vô nghĩa. Đừng ngầm định đơn tiền tệ trừ khi đã ghi rõ giả định.
  • Lưu ký hiệu thay vì mã ISO. $ nhập nhằng giữa nhiều loại tiền. Luôn dùng CHAR(3) theo ISO 4217.
  • Hardcode 2 chữ số thập phân cho mọi loại tiền. VND và JPY không có số lẻ; BHD có tới 3 số. Tra theo cấu hình currency.
  • Tính lại giá trị quy đổi theo tỷ giá hiện tại. Làm doanh thu lịch sử "trôi". Luôn snapshot tỷ giá và giá trị quy đổi tại thời điểm giao dịch.
  • Làm tròn quá sớm và quá nhiều lần. Giữ scale phụ trợ trong tính toán trung gian, chỉ làm tròn ở bước cuối.
  • Lưu tiền dạng VARCHAR hay chuỗi. Không thể tính toán, dễ chèn rác. Luôn dùng kiểu số.
Mẹo nhỏ: Khi review schema của người khác (Bài 35), cứ thấy cột tên là price, amount, total, fee, balance, salary mà kiểu là FLOAT/DOUBLE — đó là red flag số một, đánh dấu ngay.

Bài tập thực hành

  • Sửa schema sai. Cho bảng sau, hãy chỉ ra các lỗi và viết lại đúng:
   CREATE TABLE payment (
     id INT,
     amount FLOAT,
     fee DOUBLE,
     paid_symbol VARCHAR(5)
   );
   
  • Thiết kế đa tiền tệ. Một sàn TMĐT VN bán cho khách quốc tế. Hãy thiết kế bảng orders lưu được: số tiền khách trả + loại tiền, tỷ giá quy đổi về VND, và doanh thu VND đã snapshot. Giải thích vì sao cần cột snapshot.
  • Bài toán làm tròn. Sổ tiết kiệm 80.000.000 VND, lãi 5,5%/năm, tính lãi hàng ngày trong 30 ngày. Hãy mô tả: nên dùng DECIMAL(p, s) với scale bao nhiêu cho cột tích lũy lãi, và làm tròn ở bước nào? So sánh kết quả khi làm tròn mỗi ngày với khi chỉ làm tròn lần cuối.
  • Bảng danh mục currency. Bổ sung 5 dòng dữ liệu cho bảng currency ở trên: VND, USD, EUR, JPY, SGD — điền đúng số decimal_digits của từng loại.

Tóm tắt

  • Không bao giờ dùng FLOAT/DOUBLE cho tiền — sai số nhị phân tích lũy gây lệch sổ. Luôn dùng DECIMAL/NUMERIC (hoặc BIGINT minor units cho fintech).
  • Hiểu DECIMAL(precision, scale): precision là tổng chữ số, scale là số chữ số thập phân. Dùng scale lớn hơn cho tính toán trung gian, scale theo currency cho giá trị cuối.
  • (amount, currency) là một cặp bất khả phân. Luôn lưu mã ISO 4217 ba ký tự, không lưu ký hiệu.
  • Mỗi loại tiền có số chữ số thập phân riêng (VND=0, USD=2, BHD=3) — tra theo bảng danh mục, không hardcode.
  • Snapshot tỷ giá và giá trị quy đổi tại thời điểm giao dịch để báo cáo lịch sử không bao giờ trôi.
  • Làm tròn muộn, làm tròn một lần ở bước cuối cùng. Đây là quyết định mô hình dữ liệu, không phải chuyện vặt của code.
Nắm vững những nguyên tắc này, bạn đã tránh được nhóm lỗi tốn kém và khó truy vết nhất trong mọi hệ thống dính tới tiền.