AAuditPro Suite· Finance manual
Finance manual OMR money rules

Why floats are forbidden

# Float arithmetic — DON'T do this for money:
>>> 0.1 + 0.2
0.30000000000000004    # NOT 0.3 — 53-bit float can't represent decimal exactly

# bcmath — DO this:
>>> bcadd('0.1', '0.2', 3)
'0.300'                # Exact

An audit firm cannot tolerate this. A 0.001 OMR drift across 200 invoice lines = the books don't balance = audit qualification.

The 5 bcmath operators we use

OperatorUseAlways pass scale
bcadd($a, $b, 3)Addition3
bcsub($a, $b, 3)Subtraction3
bcmul($a, $b, 3)Multiplication3
bcdiv($a, $b, 3)Division3
bccomp($a, $b, 3)Comparison (returns -1/0/1)3

Working pattern — invoice line total

// Inputs always strings or string-cast numbers from DB
$qty       = '8.000';
$unitPrice = '125.000';
$discount  = '10.0';     // percent
$vatRate   = '5.0';      // percent

// Line subtotal = qty × price × (1 - discount/100)
$gross     = bcmul($qty, $unitPrice, 3);              // '1000.000'
$discFactor = bcsub('1', bcdiv($discount, '100', 4), 4);  // '0.9000'
$subtotal  = bcmul($gross, $discFactor, 3);           // '900.000'

// VAT
$lineVat   = bcmul($subtotal, bcdiv($vatRate, '100', 4), 3);  // '45.000'

// Line total
$lineTotal = bcadd($subtotal, $lineVat, 3);           // '945.000'

Storage

Column typeUse
DECIMAL(18,3)OMR amounts. 18 total digits, 3 after decimal. Max ≈ 999,999,999,999,999.999 (more than enough)
DECIMAL(5,2)Percentages (discount, VAT rate)
DECIMAL(10,6)FX rates (need more precision)

Never use FLOAT, DOUBLE, or REAL for money. Migrations enforce this.

Rounding rules

bcmath truncates toward zero by default at the requested scale. For banker's rounding (half-to-even), AuditPro uses an explicit utility:

// Helper: round to 3 decimals, half-up
function moneyRound(string $value): string {
    return bcadd($value, '0.0005', 4);  // bias up by half-tick
    // then truncate via bcadd($x, '0', 3) for final 3-decimal value
}

VAT computation rule

Per OTA: VAT computed line-by-line, NOT on the rolled-up subtotal. Worked example:

LineSubtotalVAT 5% per-lineTotal per-line
AOMR 100.0055.000 (truncated to 3 dp)105.005
BOMR 200.01510.001210.016
Σ300.02015.001315.021

If VAT were rolled up (300.020 × 5% = 15.001), the result is the same here, but for floating-point boundary cases, line-by-line is safer + auditor-defensible.

Currency conversion

If invoice is in non-OMR (e.g. USD), the system stores both:

FX gain/loss on settlement is currently out of scope (deferred until needed). For now, the invoice receives payment in the original currency + same fx_rate is used for the receipt.

Display formatting

The Helpers/Currency utility provides Currency::format($value, $currency = 'OMR'):

Currency::format('1234567.890', 'OMR')
// → 'OMR 1,234,567.890'

Currency::format('5000', 'OMR')
// → 'OMR 5,000.000'

Currency::format('-250.500', 'OMR')
// → '(OMR 250.500)'   // accounting convention for negative
Try this

In any PHP file, run echo bcadd('0.1', '0.2', 3); — outputs 0.300 (exact). Now run echo 0.1 + 0.2; — outputs 0.30000000000000004. That tiny difference, multiplied across hundreds of invoice lines, is why we never use floats for money.

Watch out

If you call any money method with a PHP float (e.g. 4.5 instead of '4.500'), bcmath will silently lose precision because PHP casts float → string with finite digits. Always pass strings. The DB returns strings already; if you cast to int/float for typing, you've broken the chain.

Tip — comparison gotcha

Don't use PHP == or === on bcmath strings: '5.000' == '5' returns false (string comparison). Use bccomp($a, $b, 3) === 0 for equality. The system encapsulates this in a Currency::eq($a, $b) helper for clarity.