Xử lý số liệu tài chính trong JavaScript
Khi làm việc với tiền tệ, từ khâu nhập liệu trên giao diện (UI) cho đến tính toán logic đều cần kiến trúc đặc biệt. Dưới đây là các sai lầm phổ biến và cách khắc phục định chuẩn quốc tế.
1. Khâu Nhập liệu (UI Component)
Cách làm sai
Sử dụng <input type="number" />
- Trượt cuộn chuột: Vô tình lăn chuột trên input làm nhảy số sai lệch.
- Giới hạn an toàn: Nhập số dài (VD: 16 chữ số 9 khổng lồ), Browser tự ép tròn thành 10000000000000000 làm mất độ chính xác vĩnh viễn (Limit của Float64).
- Nhập thập phân: Trình duyệt có thể tự xoá luôn phần số 0 lẻ phía sau (10.0 -> 10).
Cách làm đúng
Sử dụng <input type="text" inputMode="decimal" />
- Kiểm soát hoàn toàn (Regex): Chặn mọi kí tự lạ ngoài số và dấu chấm, miễn nhiễm việc cuộn chuột gây sai lệch.
- Hoàn hảo cho BigInt: Lưu trữ dữ liệu thô dưới dạng String. Vượt qua giới hạn MAX_SAFE_INTEGER để sẵn sàng convert sang chuỗi BigInt nguyên thủy.
- UX trên Mobile:
inputMode="decimal"vẫn kích hoạt bàn phím số (Numpad) hệt như Number Input.
2. Khâu Chuyển đổi (Parser String -> BigInt)
Bạn không được phép dùng parseFloat() hay Number() để ép chuỗi về số trước khi đổi qua BigInt, vì nó sẽ lại vướng vào sai số Float64. Cách duy nhất để biến Input String thành Số là cắt xén chuỗi (String Manipulation) và bù đắp các số 0 đằng sau theo hệ số Scaling ($10^6$).
- 1. Miễn nhiễm Float64: Vì mọi logic biến đổi chỉ dùng phép nối chữ (String Concatenation), nên không bao giờ xuất hiện dư âm thập phân dạng ngầm
0.000000000000004như các hàm Parse số cổ điển. - 2. Bất tử trước MAX_SAFE_INTEGER: Nếu người dùng nhập
"99999999999999999.99", thao tác cắt ghép String chả hề hấn gì. Sau đó, việc chuyền thẳng String vào mồmBigInt()sẽ giúp nuốt gọn lượng chữ số vô cực mà Number mặc định sẽ khiến trình duyệt treo hoặc làm méo dữ liệu.
3. Khâu Tính Toán (Logic)
Cách làm sai
Sử dụng JavaScript Number tiêu chuẩn
JavaScript sử dụng số thực dấu phẩy động 64-bit (IEEE 754). Hệ nhị phân không thể biểu diễn chính xác các phân số thập phân (như 0.1), dẫn đến sai lệch làm tròn khi thực hiện tính toán.
Cách làm đúng
Dùng BigInt với hệ số mở rộng (Scaling)
Để tránh sai số thập phân, thực tiễn tốt nhất là quy đổi số tiền về các đơn vị siêu nhỏ (micro-cents) thành số nguyên, sau đó sử dụng BigInt để đảm bảo hoàn toàn không bị mất mát dữ liệu.
Hoàn toàn được. Khác với Number bị giới hạn bởi MAX_SAFE_INTEGER, BigInt hỗ trợ số nguyên lớn với độ dài tuỳ ý. Bạn có thể dùng 10^10 hay 10^18 (được dùng nhiều trên Blockchain) mà không lo tràn số. Tuy nhiên, 10^6 (6 chữ số thập phân) thường được chọn làm tiêu chuẩn tối ưu vì nó vừa đủ dung sai cho các phép tính tỷ giá vi mô, vừa không gây lãng phí dung lượng lưu trữ bộ nhớ dư thừa trong Database.
4. Thực chiến: Tính tổng tiền & Gửi API
Phép nhân JS Number
Phép nhân BigInt
Giải mã luồng tính toán (Scale: 1.000.000)
= 49539595901075450500000000000n
= 49539595901075450500000n Lưu DB (Micro-cents)
Cấu trúc JSON Gửi API
Tuyệt đối không gửi Data dạng Float Number. Hãy gửi JSON với các giá trị số dưới dạng String nhằm tránh việc JSON Parser mặc định của Backend phân tích sai mảng bộ nhớ. Hoặc an toàn hơn, gửi bằng hệ số nguyên Micro-cents.
{
"orderId": "ORD-001",
// --- Cách 1: Gửi chuỗi chưa phân giải (Khuyên dùng)
// Backend tự xử lý bằng thư viện Decimal gốc
"price_str": "9007199254740991",
"quantity_str": "5.5",
"total_str": "49539595901075450.5",
// --- Cách 2: Gửi số nguyên siêu nhỏ (Micro-cents)
// Map hoàn hảo với int64 Database / gRPC Protobuf
"price_micro": 9007199254740991000000,
"quantity_micro": 5500000,
"total_micro": 49539595901075450500000
}5. FAQ: Các câu hỏi cấu trúc thường gặp
Q:Backend sẽ lưu trữ Data trong Database dưới định dạng String (Chữ)?
Khi Database hoặc Backend nhận chuỗi String đó, nó sẽ tự động phân tích ra cấu trúc Decimal tiêu chuẩn (Ví dụ: Java phân giải thành
BigDecimal). Ghi vào Database (MySQL/PostgreSQL), Data thường được khai báo với column type DECIMAL(19, 4).*Lưu ý: Ở các dự án hệ thống lớn hoặc cổng thanh toán như Stripe, xu hướng mới là lưu trực tiếp bằng Cents/Micro-cents dưới dạng Data
BIGINT trên phần cứng Database.Q:Sử dụng API lấy Data trả về (GET Request), cục số tiền trả về bắt buộc phải là String?
Tại sao? Bởi vì khi frontend call API và thực thi hàm
res.json(), JSON Parser mặc định rất dở. Dù con số {"total": 9999999999999999.5} do backend cung cấp hoàn toàn đúng 100%, nhưng khi Javascript tự động Parse Object, nó sẽ quét nhầm và ném bớt đi phần dư vì bị vướng bẫy giới hạn Float64 Max Safe Integer!Sự phá hoại xảy ra tận màng phân duyệt ngầm trước cả khi code của bạn kịp đụng chạm tới dữ liệu. Vậy nên, ép Backend trả chuỗi đóng gói dạng
{"total": "9999999999999999.5"} là phương pháp bịt đường hở an toàn tuyệt đối.Q:Nhưng nếu dùng BigInt, làm sao xử lý phép chia lẻ? VD: Chia bill 100đ cho 3 người thì BigInt làm mất phần lẻ thập phân?
Trong ứng dụng tài chính thực, chúng ta vớt lại phần dư bằng Cấu trúc Chia kết hợp Modulo:
- Thương cứng chia 3:
100n / 3n = 33n- Số bạc lẻ dư thừa:
100n % 3n = 1nThuật toán lúc này sẽ tự động gán 2 người đầu thanh toán 33đ, còn người cuối cùng sẽ móc túi thanh toán nốt cục nợ
33đ + 1đ = 34đ. Bằng cách này, tổng cộng thu về vẫn là 100đ và không 1 đồng nào bị biến mất do máy tính tự làm tròn. Trói buộc của BigInt ép lập trình viên tuân thủ đạo luật giải quyết chia chác minh bạch 100%.Q:Tại sao lại phải khổ sở viết cấu trúc xử lý chay thay vì download thư viện như decimal.js hay bignumber.js cho nhàn?
new Decimal(x).add(new Decimal(y))).Lý do tuyệt đối là Lõi BigInt được tích hợp Native sâu trong V8 Engine của JS bằng C++ từ bản chuẩn hóa công nghệ năm 2020. Bạn có thể tự do dùng các toán hạng tự nhiên
a * b hay a / b có sẵn mang tốc độ chớp nhoáng mà không một thư viện NPM ngoài nào đua kịp.Triển khai kiến trúc Wrapping tự Scale string
10^6 vỏn vẹn 5 dòng code của chúng ta thay thế thẳng tay toàn bộ gánh nặng của thư viện ngoài kia, được gọi là định chuẩn thiết kế ứng dụng khắt khe: Zero-dependency - High Performance.Q:Vấn đề chia Modulo lấy phần dư ở trên (100n / 3n = 33n dư 1n), vậy Case thực tế mang đi ứng dụng vào Code của dự án thì viết thuật toán thế nào?
Thay vì loay hoay ép kiểu với hệ thống Float tự động làm tròn sai lệch của thư viện, hệ thống sẽ chia sòng phẳng phần nguyên, đếm tổng số phần lượng dư, và thả bù đắp từng 1 đồng lẻ dư đó vào những người đầu tiên trong mảng để tổng quỹ tiền tự động bảo toàn 100%. Tham khảo cấu trúc sau:
// Thuật toán chia rẽ nhánh siêu tốc (Không rớt 1 Micro-cent)
function splitBill(totalAmount: bigint, peopleCount: bigint) {
const baseSplit = totalAmount / peopleCount;
const remainder = totalAmount % peopleCount;
const results = [];
for (let i = 0n; i < peopleCount; i++) {
// Nếu thứ tự i thấp hơn bộ đếm phần dư, phân bổ bù +1đ. Cân bằng thì +0
const extra = i < remainder ? 1n : 0n;
results.push(baseSplit + extra);
}
return results;
}
// Ví dụ Case Study: 100n chia đều 3 người
splitBill(100n, 3n); // -> [34n, 33n, 33n] (Người đầu chịu bù 1n nợ dôi dư)Không hề rối rắm phải không? Thuật toán tối giản dựa sức mạnh lõi của Native JS BigInt này là đủ để chia chính xác hàng trăm tỷ Transaction phân phối thanh toán (Settlements) của các Enterprise Node hàng năm mà không hề lo sợ tràn biến hoặc thất lạc tiền. Cắm logic chạy là ăn!
Q:Việc tự gõ code quản lý phân tích BigInt và Scale String nhìn có vẻ khó và rườm rà. Bạn có thể viết 1 Class đơn giản bọc lại để dùng chung cho toàn dự án được không?
Với Class này, anh/chị chỉ cần gọi mã
new Money("19.99").multiply("2") vô cùng tường minh (OOP). Em xin tặng anh đoạn Code chuẩn mực dưới đây:// src/utils/Money.ts
export class Money {
private value: bigint;
private static readonly DECIMALS = 6;
private static readonly SCALE = 1_000_000n; // 10^DECIMALS
constructor(amount: string | bigint | Money) {
if (amount instanceof Money) {
this.value = amount.value;
} else if (typeof amount === "bigint") {
this.value = amount;
} else {
// Hàm scale String tự động chuẩn hoá khi khởi tạo
const [intPart = "0", fracPart = ""] = amount.split(".");
const paddedFrac = fracPart.padEnd(Money.DECIMALS, "0").slice(0, Money.DECIMALS);
this.value = BigInt(intPart + paddedFrac);
}
}
add(other: string | Money): Money {
return new Money(this.value + new Money(other).value);
}
subtract(other: string | Money): Money {
return new Money(this.value - new Money(other).value);
}
multiply(other: string | Money): Money {
return new Money((this.value * new Money(other).value) / Money.SCALE);
}
divide(other: string | Money): Money {
return new Money((this.value * Money.SCALE) / new Money(other).value);
}
toString(): string {
const isNeg = this.value < 0n;
const absStr = (isNeg ? -this.value : this.value).toString().padStart(Money.DECIMALS + 1, "0");
const intPart = absStr.slice(0, -Money.DECIMALS);
const fracPart = absStr.slice(-Money.DECIMALS).replace(/0+$/, "");
return (isNeg ? "-" : "") + intPart + (fracPart ? "." + fracPart : "");
}
}
// ------ CÁCH SỬ DỤNG TRONG THỰC TẾ ------
const price = new Money("19.99");
const qty = new Money("5");
// Tính thành tiền và in thẳng ra Chuỗi an toàn để đưa vào API
const total = price.multiply(qty);
console.log(total.toString()); // -> "99.95"
// 1. Chữa vĩnh viễn lỗi dở khóc dở cười 0.1 + 0.2
const num1 = new Money("0.1");
const num2 = new Money("0.2");
console.log(num1.add(num2).toString()); // -> "0.3" (Không bao giờ xuất hiện 0.3000...4)
// 2. Tính tiền áp dụng chiết khấu lẻ (Discount Percent)
const fund = new Money("100");
const discount = fund.multiply("0.15"); // Phạt/Giảm giá 15%
console.log(fund.subtract(discount).toString()); // -> "85"
Chỉ với một Class dài chưa tới 40 dòng Code mà dự án đã sở hữu ngay một cỗ máy tính toán chuẩn mực: Không phụ thuộc thư viện tải ngoài nào (Zero Dependency / Bundle Size 0KB), và tốc độ xử lý nhanh như chớp.
Đội ngũ lập trình viên từ nay về sau chỉ cần gọi
new Money() rồi tha hồ bắn chuỗi `.add()` hoặc `.multiply()`. Mọi lớp xử lý bóc tách String và Scale tự động đều đã được Class này đóng gói giấu sâu dưới gầm!