پرش به محتوای اصلی
فین‌تک و حسابداری
۷ دقیقه مطالعه

چرا در نرم‌افزار حسابداری نباید با Float حساب کرد؟ (و به‌جایش چه کنیم)

خطای پنهان اعداد اعشاری IEEE 754 در نرم‌افزار حسابداری و فین‌تک، اینکه چرا ریال صحیح هم شما را نجات نمی‌دهد، و چهار قاعده‌ای که در سامانه ۲حساب برای دقت ریالی رعایت کرده‌ام.

برنامه‌نویس فول‌استک

اگر در کنسول مرورگر بنویسید 0.1 + 0.2، جواب 0.30000000000000004 است، نه 0.3. در یک ماشین‌حساب این تفاوت مهم نیست، اما در نرم‌افزار حسابداری یعنی ترازی که با یک عدد بسیار کوچک صفر نمی‌شود، گزارشی که با گزارش دیگر یک ریال اختلاف دارد و حسابداری که چند ساعت دنبال خطایی می‌گردد که در دفترها نیست، در کد است. وقتی سامانه حسابداری ۲حساب را می‌ساختم، از روز اول یک قاعده گذاشتم: هیچ مبلغی، هیچ‌جا، با عدد اعشاری معمولی حساب نمی‌شود. این مقاله توضیح می‌دهد چرا، و این قاعده در عمل چه شکلی دارد.

دقیقاً چه چیزی خراب می‌شود؟

تقریباً همه زبان‌ها اعداد اعشاری را طبق استاندارد IEEE 754 و در مبنای دو نگه می‌دارند. مشکل اینجاست که بسیاری از کسرهای ساده مبنای ده، مثل ۰٫۱، در مبنای دو نمایش متناهی ندارند؛ درست مثل یک‌سوم که در مبنای ده ۰٫۳۳۳… می‌شود و هیچ‌وقت تمام نمی‌شود. پس عدد ذخیره‌شده فقط «نزدیک» به ۰٫۱ است.

این خطای کوچک در سه جا خودش را نشان می‌دهد. اول در مقایسه: شرطی مثل «اگر جمع بدهکار برابر جمع بستانکار است» با === گاهی false می‌شود، در حالی که روی کاغذ سند تراز است. دوم در جمع‌های طولانی: وقتی هزاران ردیف فاکتور را جمع می‌زنید، خطاها روی هم جمع می‌شوند و بعد از گرد کردن، جمع ستون با جمع گزارش دیگر یک واحد اختلاف پیدا می‌کند. سوم در گرد کردن: عددی که باید ۲٫۵ باشد ممکن است در حافظه ۲٫۴۹۹۹۹۹۹ باشد و به‌جای بالا، پایین گرد شود.

بدترین بخش این است که این خطاها در تست‌های ساده دیده نمی‌شوند. با چند عدد گرد و تمیز همه‌چیز درست است و مشکل وقتی بیرون می‌زند که داده واقعی و حجم واقعی وارد سیستم شده.

javascript
0.1 + 0.2;                 // 0.30000000000000004
1.1 * 3;                   // 3.3000000000000003
(1.005).toFixed(2);        // "1.00" نه "1.01"
0.1 + 0.2 === 0.3;         // false

«ما که با ریال کار می‌کنیم و اعشار نداریم»

این رایج‌ترین جوابی است که می‌شنوم. درست است که مبلغ فاکتور در ایران معمولاً عدد صحیح ریالی است و اعداد صحیح تا حدود ۹ میلیون میلیارد در جاوااسکریپت دقیق می‌مانند. اما اعشار از جاهای دیگر وارد حساب می‌شود:

۱. درصدها: تخفیف ۷٫۵ درصدی، کارمزد، و درصد مالیات بر ارزش افزوده روی مبلغی که بر ۱۰۰ بخش‌پذیر نیست.

۲. مقدارهای اعشاری: کالایی که با کیلو، متر یا لیتر فروخته می‌شود؛ ۲٫۳۵ کیلو ضرب در قیمت واحد.

۳. نرخ ارز: در سیستم چندارزی، نرخ تبدیل تقریباً همیشه اعشار دارد.

۴. بهای تمام‌شده: میانگین موزون یا تقسیم هزینه حمل بین اقلام یک خرید، به عددی می‌رسد که صحیح نیست.

پس حتی در سیستمی که خروجی‌اش ریال صحیح است، محاسبات میانی اعشار دارند. سؤال این نیست که اعشار داریم یا نه؛ این است که اعشار را دقیق نگه می‌داریم یا تقریبی.

قاعده ۱: ذخیره در دیتابیس با NUMERIC

اولین جا دیتابیس است. ستون مبلغ نباید از نوع FLOAT یا REAL یا DOUBLE PRECISION باشد. در PostgreSQL نوع NUMERIC (و در MySQL نوع DECIMAL) عدد را در مبنای ده و دقیق نگه می‌دارد. دقت و مقیاس را آگاهانه انتخاب کنید: برای مبلغ ریالی، مقیاس صفر کافی است؛ برای مقدار کالا یا نرخ ارز، چند رقم اعشار لازم است.

در ۲حساب مبالغ، مقادیر و نرخ‌ها هر کدام نوع NUMERIC مخصوص خودشان را دارند. این کار جلوی خطای بی‌صدای دیتابیس را می‌گیرد: اگر عددی بیشتر از ظرفیت ستون باشد، خطا می‌گیرید، نه اینکه بی‌صدا گرد شود.

sql
CREATE TABLE invoice_lines (
  id          BIGSERIAL PRIMARY KEY,
  invoice_id  BIGINT NOT NULL REFERENCES invoices(id),
  product_id  BIGINT NOT NULL REFERENCES products(id),
  quantity    NUMERIC(14, 3) NOT NULL CHECK (quantity > 0),   -- کیلو و متر هم
  unit_price  NUMERIC(20, 0) NOT NULL CHECK (unit_price >= 0), -- ریال
  discount    NUMERIC(20, 0) NOT NULL DEFAULT 0,
  tax         NUMERIC(20, 0) NOT NULL DEFAULT 0,
  line_total  NUMERIC(20, 0) NOT NULL
);

نکات کلیدی

  • ستون‌های مالی: NUMERIC یا DECIMAL، هرگز FLOAT.
  • مقیاس هر ستون را بر اساس معنایش انتخاب کنید (مبلغ، مقدار، نرخ).
  • قید CHECK برای مقادیر غیرمنفی، تا داده خراب اصلاً وارد نشود.

قاعده ۲: در مسیر انتقال، عدد را رشته نگه دارید

دیتابیس درست است، اما داده باید از API به مرورگر برسد. JSON نوع «عدد دقیق» ندارد و هر عددی که JSON.parse بخواند به double جاوااسکریپت تبدیل می‌شود. اینجا دومین تله پنهان است.

درایور pg در Node.js به‌طور پیش‌فرض NUMERIC را به‌صورت رشته برمی‌گرداند، دقیقاً برای اینکه دقت از بین نرود. خطای رایج این است که برنامه‌نویس برای راحتی Number(row.amount) یا parseFloat می‌نویسد و تمام زحمت دیتابیس را دور می‌ریزد. در ۲حساب مبالغ در API به‌صورت رشته رفت‌وآمد می‌کنند و فقط در دو جا به عدد دقیق تبدیل می‌شوند: موقع محاسبه با کتابخانه Decimal و موقع نمایش.

نکات کلیدی

  • خروجی NUMERIC از دیتابیس را با Number یا parseFloat تبدیل نکنید.
  • در API مبلغ را رشته بفرستید و بگیرید و در ورودی با الگوی عددی اعتبارسنجی کنید.
  • قالب‌بندی نمایش (جداکننده هزارگان، ارقام فارسی) فقط در لایه نمایش.

قاعده ۳: محاسبه با decimal.js و گرد کردن در نقطه تعریف‌شده

برای محاسبه از کتابخانه‌ای مثل decimal.js استفاده می‌کنم که حساب را در مبنای ده و با دقت دلخواه انجام می‌دهد. اما کتابخانه به‌تنهایی کافی نیست؛ دو تصمیم باید صریح گرفته شود.

اول، روش گرد کردن. گرد کردن معمولی (نیم به بالا) با گرد کردن بانکی (نیم به نزدیک‌ترین زوج) نتیجه متفاوت می‌دهد و در جمع هزاران ردیف، این تفاوت دیده می‌شود. این تصمیم مالی است، نه برنامه‌نویسی؛ باید با حسابدار مجموعه گرفته شود و بعد در کد ثابت بماند.

دوم، نقطه گرد کردن. مالیات را روی هر ردیف حساب و گرد می‌کنید، یا روی جمع فاکتور؟ این دو در بسیاری از فاکتورها یک ریال اختلاف دارند. هر کدام را انتخاب کنید، باید همه‌جای سیستم (فاکتور، گزارش، سند) همان را استفاده کنند. بیشتر اختلاف‌های «یک ریالی» که دیده‌ام از همین ناهماهنگی بوده، نه از خود Float.

typescript
import Decimal from 'decimal.js';

Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });

/** یک ردیف فاکتور؛ همه ورودی‌ها رشته‌اند تا دقت از دیتابیس تا اینجا حفظ شود. */
export function lineTotal(q: string, unitPrice: string, discountPct: string, taxPct: string) {
  const gross = new Decimal(q).mul(unitPrice);
  const discount = gross.mul(discountPct).div(100).toDecimalPlaces(0);  // گرد کردن: ریال
  const net = gross.minus(discount).toDecimalPlaces(0);
  const tax = net.mul(taxPct).div(100).toDecimalPlaces(0);              // مالیات روی هر ردیف
  return {
    gross: gross.toFixed(0),
    discount: discount.toFixed(0),
    tax: tax.toFixed(0),
    total: net.plus(tax).toFixed(0),
  };
}

قاعده ۴: هیچ سندی بدون تراز دقیق ثبت نشود

در حسابداری دوطرفه، جمع بدهکار هر سند باید دقیقاً برابر جمع بستانکار باشد. این کنترل را نباید به گزارش تراز آخر ماه سپرد؛ باید در لحظه ثبت، پیش از نوشتن در دیتابیس، انجام شود و سند ناتراز اصلاً ثبت نشود. با Decimal این مقایسه دقیق است و مشکل 0.30000000000000004 پیش نمی‌آید.

کنترل دوم در خود دیتابیس است: ثبت سند و ردیف‌هایش داخل یک تراکنش انجام می‌شود تا هیچ‌وقت سندی با نیمی از ردیف‌ها باقی نماند. اگر به هر دلیلی یکی از ردیف‌ها ثبت نشود، کل سند برمی‌گردد.

typescript
import Decimal from 'decimal.js';

type Row = { account: string; debit: string; credit: string };

export function assertBalanced(rows: Row[]) {
  const debit = rows.reduce((s, r) => s.plus(r.debit || 0), new Decimal(0));
  const credit = rows.reduce((s, r) => s.plus(r.credit || 0), new Decimal(0));
  if (rows.some((r) => new Decimal(r.debit || 0).gt(0) && new Decimal(r.credit || 0).gt(0))) {
    throw new Error('هر ردیف فقط بدهکار یا فقط بستانکار است.');
  }
  if (!debit.eq(credit)) {
    throw new Error(`سند تراز نیست: بدهکار ${debit.toFixed()} ≠ بستانکار ${credit.toFixed()}`);
  }
}

جایگزین ساده‌تر: عدد صحیح در کوچک‌ترین واحد

راه دیگری هم هست که در سیستم‌های پرداخت رایج است: همه مبالغ را به‌صورت عدد صحیح در کوچک‌ترین واحد پول (مثلاً ریال، یا سنت برای دلار) در ستون BIGINT نگه دارید. جمع و تفریق اعداد صحیح همیشه دقیق است.

این روش برای کیف پول یا درگاه پرداخت که فقط مبلغ جابه‌جا می‌کند خوب است. اما وقتی ضرب و تقسیم وارد می‌شود (درصد، نرخ ارز، مقدار اعشاری، میانگین بهای تمام‌شده) دوباره به عدد غیرصحیح می‌رسید و باید درباره گرد کردن تصمیم بگیرید. برای نرم‌افزار حسابداری و انبارداری، NUMERIC به‌همراه Decimal را ترجیح می‌دهم چون هم محاسبات میانی دقیق می‌ماند و هم خوانایی داده در دیتابیس حفظ می‌شود.

چک‌لیست برای نرم‌افزار مالی خودتان

اگر نرم‌افزار حسابداری یا فروش دارید و گاهی گزارش‌ها با هم یک ریال اختلاف دارند، این موارد را به ترتیب بررسی کنید:

پیاده‌سازی واقعی این قاعده‌ها را در سامانه حسابداری ۲حساب می‌توانید ببینید. همین دقت در محاسبه بهای تمام‌شده هم لازم است که در کاردکس کالا و روش FIFO توضیح داده‌ام.

نکات کلیدی

  • نوع ستون‌های مبلغ در دیتابیس FLOAT یا DOUBLE نباشد.
  • هیچ‌جای کد مبلغ را با Number یا parseFloat تبدیل نکند.
  • همه محاسبات مالی با کتابخانه Decimal انجام شود.
  • روش و نقطه گرد کردن یک‌جا تعریف شده و همه بخش‌ها از همان استفاده کنند.
  • تراز سند پیش از ثبت و داخل تراکنش کنترل شود.

جمع‌بندی

Float برای محاسبات علمی ساخته شده، نه برای پول. در نرم‌افزار حسابداری، حتی اگر مبالغ ریال صحیح باشند، درصدها، مقدارهای اعشاری و نرخ ارز اعشار وارد محاسبه می‌کنند. چهار قاعده کافی است: NUMERIC در دیتابیس، رشته در API، decimal.js با روش و نقطه گرد کردن صریح در محاسبه، و کنترل تراز هر سند پیش از ثبت. اگر نرم‌افزار حسابداری اختصاصی لازم دارید که از روز اول با همین دقت ساخته شود، صفحه طراحی نرم‌افزار حسابداری و انبارداری را ببینید.

فین‌تک و حسابداری
۶ دقیقه مطالعه

کاردکس کالا و روش FIFO در نرم‌افزار انبارداری: از مثال عددی تا کوئری

بهای تمام‌شده کالا به روش FIFO با مثال عددی، مقایسه با میانگین موزون، و پیاده‌سازی واقعی در نرم‌افزار انبارداری: لایه‌های موجودی، قفل ردیف، برگشت از فروش، انتقال بین انبارها و گزارش کاردکس.

مطالعه مقاله#FIFO
فین‌تک و حسابداری
۵ دقیقه مطالعه

نرم‌افزار حسابداری آماده یا اختصاصی؟ معیارهایی برای تصمیم درست

کی نرم‌افزار حسابداری آماده کافی است و کی ساخت نرم‌افزار حسابداری و انبارداری اختصاصی ارزشش را دارد؟ هفت معیار عملی برای تصمیم، هزینه‌های پنهان هر دو راه، و راه میانه.

مطالعه مقاله#نرم‌افزار حسابداری
داده و دیتابیس
۵ دقیقه مطالعه

مدل‌سازی کالای چندبعدی در دیتابیس: تجربه ماتریس نمره عدسی

کالایی که با چند عدد شناخته می‌شود (نمره عدسی، سایز و رنگ) را چطور در PostgreSQL مدل کنیم که هزاران کد کالای جدا نسازیم: واریانت با کلید ترکیبی، ذخیره نمره به‌صورت عدد صحیح، قیمت‌گذاری بازه‌ای و گزارش ماتریسی.

مطالعه مقاله#PostgreSQL