Quay lại ứng dụng

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" />

Giá trị State (Browser Parse):""
// Giao phó hoàn toàn cho Browser:
<input type="number" onChange={e => { // Giá trị đã bị ép tròn/biến đổi sai lệch setNum(e.target.valueAsNumber) }} />
  • 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" />

Giá trị State (String thô):""
// Giải pháp xử lý (Regex & String):
const handleChange = (e) => { const val = e.target.value; // Chỉ cho phép số và dấu chấm if (val === "" || /^[0-9]*\.?[0-9]*$/.test(val)) { setStringValue(val); } }; <input type="text" inputMode="decimal" onChange={handleChange} />
  • 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$).

// Hàm an toàn tuyệt đối để Scale chuỗi "19.99" thành 19990000n
const parseToBigIntScale = (valString) => { if (!valString) return 0n; // 1. Tách chuỗi thành 2 mảnh: phần nguyên và phần thập phân const [intPart, fracPart = ""] = valString.split("."); // 2. Cố định phần thập phân có đúng 6 chữ số (Scale 10^6) // Ví dụ: .99 -> .990000 (bù thêm 4 con số 0) const paddedFrac = fracPart.padEnd(6, "0").slice(0, 6); // 3. Ghép hai chuỗi lại với nhau và Parse trực tiếp // Bỏ qua hoàn toàn phép thập phân của Javascript! // "19" + "990000" -> 19990000n return BigInt(intPart + paddedFrac); };
Điểm chốt:
  • 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.000000000000004 như 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ồm BigInt() 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.

// Ví dụ thực tế về sai số:
console.log(0.1 + 0.2);
-> 0.30000000000000004
const price = 19.99;
const tax = 0.0825; // 8.25%
console.log(price * (1 + tax));
-> 21.639175000000003
Hậu quả: Lỗi làm tròn tích luỹ gây thất thoát độ chính xác, dẫn đến sai lệch trên quy mô lớn của các giao dịch.

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.

// Nhân với hệ số 10^6:
const SCALE = 1_000_000n;
const price = 19_990_000n;
const tax = 82_500n;
const total = (price * (SCALE + tax)) / SCALE;
-> 21_639_175n
// Định dạng để hiển thị: 21.639175
Kết quả: Tính toán nguyên vẹn, loại bỏ hoàn toàn khái niệm "phần thập phân", đảm bảo hệ thống tài chính luôn chính xác tuyệt đối.
Tại sao là 10^6? Có thể dùng 10^10 không?

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

// 9007199254740991 * 5.5
= 49539595901075450
⚠️ Kết quả bị sai: Giá trị đã vượt MAX_SAFE_INTEGER (9,007,199,254,740,991). Javascript tự động cắt bỏ phần thập phân hoặc làm tròn sai lệch hàng đơn vị.

Phép nhân BigInt

// Big Price: 9007199254740991000000n
// Big Qty: 5500000n
= 49539595901075450.5
Trạng thái lưu trữ BigInt: 49539595901075450500000n

Giải mã luồng tính toán (Scale: 1.000.000)

1. Chuyển String thành BigInt (Dịch mốc thập phân):"9007199254740991" ➔ Ngậm 6 số thập phân ➔ 9007199254740991000000n"5.5" ➔ Ngậm 6 số thập phân ➔ 5500000n
2. Thực hiện nhân 2 số nguyên siêu lớn (An toàn 100%):9007199254740991000000n × 5500000n
= 49539595901075450500000000000n
3. Cân bằng lại tỷ lệ (Do dư 1 lần hệ số 10^6):Tích số / 1,000,000
= 49539595901075450500000n Lưu DB (Micro-cents)
4. Hiển thị lại thành Chuỗi (Parser -> UI):Lùi dấu chấm ảo 6 số ➔ "49539595901075450.5"

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ữ)?

A:
KHÔNG. String chỉ đóng vai trò như một Lớp vận chuyển (Transport layer) trung gian qua JSON Payload trên mạng, giúp che giấu con số khỏi bộ phận biên dịch của trình duyệt (Browser Parser).

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?

A:
ĐÚNG! CHÍNH XÁC LÀ VẬY. Dù Backend lưu DB là DECIMAL hay FLOAT đi chăng nữa, thì Endpoint JSON Response trả ngược về cho Client cũng bắt buộc nên được đóng gói kiểu String (chuỗi).

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?

A:
BigInt vốn không cho phép thương số mang số thập phân (Nó sẽ tự vứt bỏ phần dư). Tuy nhiên, nguyên tắc cốt lõi của Tài chính/Kế toán chia chác là: Không bao giờ tự tiện thả trôi số dư làm thất thoát tiền của hệ thống.

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 = 1n

Thuậ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?

A:
Thư viện thứ 3 cực kỳ tuyệt vời nếu bạn không màng đến Bundle Size (hàng chục KB file nén tải xuống browser của người dùng). Ngoài ra hệ mã của thư viện tạo hiệu năng chậm do liên tục nhả các Object instance dư thừa lúc tính toán (Ví dụ: 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?

A:
Trong các hệ thống sàn TMĐT (Shopee) hoặc ví điện tử (Stripe), việc này sẽ được module hóa thành một thuật toán "Phân bổ dư nợ ngang hàng" (Greedy Remainder Allocation) cực kỳ gọn gàng.

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?

A:
Câu hỏi rất tinh tế! Trong thực tế, không ai đi cày chay Parser ở từng component cả. Team kỹ sư thường sẽ gom toàn bộ chức năng vào đúng 1 file Class `Money` duy nhất cực kỳ gọn nhẹ (Zero Dependency) rồi Import dùng xuyên suốt dự án.

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!

Switching to Light...