diff --git a/backend/package-lock.json b/backend/package-lock.json index c3162122..2803b204 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -63,7 +63,7 @@ "jest": "^30.2.0", "prettier": "^3.1.1", "supertest": "^7.2.2", - "ts-jest": "^29.4.6", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsx": "^4.21.0", @@ -6260,9 +6260,9 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9471,9 +9471,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10222,19 +10222,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -10251,7 +10251,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { diff --git a/backend/package.json b/backend/package.json index 4b923526..746a535b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -74,10 +74,10 @@ "jest": "^30.2.0", "prettier": "^3.1.1", "supertest": "^7.2.2", - "ts-jest": "^29.4.6", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsx": "^4.21.0", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/backend/src/services/benefitsService.ts b/backend/src/services/benefitsService.ts index dff56e77..b66df3c3 100644 --- a/backend/src/services/benefitsService.ts +++ b/backend/src/services/benefitsService.ts @@ -1,4 +1,5 @@ import pool from '../config/database.js'; +import { Money } from '../utils/money.js'; export interface BenefitPlan { id: number; @@ -61,7 +62,7 @@ export interface DraftPayslip { } function round7(n: number): number { - return parseFloat(n.toFixed(7)); + return Money.from(n).toNumber(); } export class BenefitsService { @@ -337,8 +338,11 @@ export class BenefitsService { } const ruleValue = parseFloat(rule.value); + const grossMoney = Money.from(gross); const amount = - rule.type === 'percentage' ? round7(gross * (ruleValue / 100)) : round7(ruleValue); + rule.type === 'percentage' + ? grossMoney.percentage(ruleValue).toNumber() + : Money.from(ruleValue).toNumber(); let destinationWallet = rule.destination_wallet_address; if (!destinationWallet) { @@ -372,8 +376,11 @@ export class BenefitsService { for (const tax of taxResult.rows as Array<{ id: number; name: string; type: 'percentage' | 'fixed'; value: string }>) { const taxValue = parseFloat(tax.value); + const grossMoney = Money.from(gross); const amount = - tax.type === 'percentage' ? round7(gross * (taxValue / 100)) : round7(taxValue); + tax.type === 'percentage' + ? grossMoney.percentage(taxValue).toNumber() + : Money.from(taxValue).toNumber(); const treasuryWallet = await this.resolveTreasuryWalletAddress(input.organization_id, currency); @@ -389,8 +396,12 @@ export class BenefitsService { }); } - const totalDeductions = round7(lines.reduce((sum, l) => sum + l.amount, 0)); - const net = Math.max(0, round7(gross - totalDeductions)); + let totalDeductionsMoney = Money.zero(); + for (const l of lines) { + totalDeductionsMoney = totalDeductionsMoney.add(Money.from(l.amount)); + } + const totalDeductions = totalDeductionsMoney.toNumber(); + const net = Math.max(0, Money.from(gross).sub(totalDeductionsMoney).toNumber()); return { organization_id: input.organization_id, diff --git a/backend/src/services/payrollBonusService.ts b/backend/src/services/payrollBonusService.ts index e2dba1f4..92a71e8c 100644 --- a/backend/src/services/payrollBonusService.ts +++ b/backend/src/services/payrollBonusService.ts @@ -1,4 +1,5 @@ import { pool } from '../config/database.js'; +import { Money } from '../utils/money.js'; import logger from '../utils/logger.js'; export interface PayrollRun { @@ -214,17 +215,26 @@ export class PayrollBonusService { const baseItems = items.filter((item) => item.item_type === 'base'); const bonusItems = items.filter((item) => item.item_type === 'bonus'); + let baseAmountMoney = Money.zero(); + for (const item of baseItems) { + baseAmountMoney = baseAmountMoney.add(Money.from(item.amount)); + } + let bonusAmountMoney = Money.zero(); + for (const item of bonusItems) { + bonusAmountMoney = bonusAmountMoney.add(Money.from(item.amount)); + } + let totalAmountMoney = Money.zero(); + for (const item of items) { + totalAmountMoney = totalAmountMoney.add(Money.from(item.amount)); + } + const summary = { total_employees: uniqueEmployees.size, total_base_items: baseItems.length, total_bonus_items: bonusItems.length, - total_base_amount: baseItems - .reduce((sum, item) => sum + parseFloat(item.amount), 0) - .toFixed(7), - total_bonus_amount: bonusItems - .reduce((sum, item) => sum + parseFloat(item.amount), 0) - .toFixed(7), - total_amount: items.reduce((sum, item) => sum + parseFloat(item.amount), 0).toFixed(7), + total_base_amount: baseAmountMoney.toFixed(7), + total_bonus_amount: bonusAmountMoney.toFixed(7), + total_amount: totalAmountMoney.toFixed(7), by_status: { pending: items.filter((item) => item.status === 'pending').length, completed: items.filter((item) => item.status === 'completed').length, diff --git a/backend/src/services/taxService.ts b/backend/src/services/taxService.ts index 2328fd1b..4fdaa182 100644 --- a/backend/src/services/taxService.ts +++ b/backend/src/services/taxService.ts @@ -1,5 +1,6 @@ import { Pool } from 'pg'; import pool from '../config/database.js'; +import { Money } from '../utils/money.js'; import { createTaxComplianceProvider } from './taxCompliance/factory.js'; import type { TaxComplianceProvider } from './taxCompliance/types.js'; @@ -245,15 +246,16 @@ export class TaxService { ): Promise { const rules = await this.getRules(organizationId); const deductions: TaxDeduction[] = []; - let totalTax = 0; + const gross = Money.from(grossAmount); + let totalTax = Money.zero(); for (const rule of rules) { - let deductedAmount = 0; + let deductedAmount = Money.zero(); if (rule.type === 'percentage') { - deductedAmount = parseFloat((grossAmount * (Number(rule.value) / 100)).toFixed(7)); + deductedAmount = gross.percentage(Number(rule.value)); } else if (rule.type === 'fixed') { - deductedAmount = parseFloat(Number(rule.value).toFixed(7)); + deductedAmount = Money.from(Number(rule.value)); } deductions.push({ @@ -261,19 +263,19 @@ export class TaxService { rule_name: rule.name, type: rule.type, rule_value: Number(rule.value), - deducted_amount: deductedAmount, + deducted_amount: deductedAmount.toNumber(), }); - totalTax += deductedAmount; + totalTax = totalTax.add(deductedAmount); } - const netAmount = parseFloat((grossAmount - totalTax).toFixed(7)); + const netAmount = gross.sub(totalTax); return { gross_amount: grossAmount, deductions, - total_tax: parseFloat(totalTax.toFixed(7)), - net_amount: Math.max(0, netAmount), + total_tax: totalTax.toNumber(), + net_amount: Math.max(0, netAmount.toNumber()), }; } @@ -340,21 +342,27 @@ export class TaxService { const entries: TaxReportEntry[] = result.rows.map((row: any) => ({ rule_name: row.rule_name, rule_type: row.rule_type, - rule_value: parseFloat(row.rule_value), - total_gross: parseFloat(row.total_gross), - total_tax: parseFloat(row.total_tax), - total_net: parseFloat(row.total_net), + rule_value: Money.from(row.rule_value).toNumber(), + total_gross: Money.from(row.total_gross).toNumber(), + total_tax: Money.from(row.total_tax).toNumber(), + total_net: Money.from(row.total_net).toNumber(), transaction_count: parseInt(row.transaction_count, 10), })); - const summary = entries.reduce( - (acc, entry) => ({ - total_gross: acc.total_gross + entry.total_gross, - total_tax: acc.total_tax + entry.total_tax, - total_net: acc.total_net + entry.total_net, - }), - { total_gross: 0, total_tax: 0, total_net: 0 } - ); + let totalGross = Money.zero(); + let totalTaxSum = Money.zero(); + let totalNetSum = Money.zero(); + for (const entry of entries) { + totalGross = totalGross.add(Money.from(entry.total_gross)); + totalTaxSum = totalTaxSum.add(Money.from(entry.total_tax)); + totalNetSum = totalNetSum.add(Money.from(entry.total_net)); + } + + const summary = { + total_gross: totalGross.toNumber(), + total_tax: totalTaxSum.toNumber(), + total_net: totalNetSum.toNumber(), + }; return { organization_id: organizationId, diff --git a/backend/src/utils/__tests__/money.test.ts b/backend/src/utils/__tests__/money.test.ts new file mode 100644 index 00000000..6d497075 --- /dev/null +++ b/backend/src/utils/__tests__/money.test.ts @@ -0,0 +1,222 @@ +import { Money } from '../money.js'; + +describe('Money', () => { + describe('from', () => { + it('should create from integer', () => { + const m = Money.from(1000); + expect(m.toNumber()).toBe(1000); + }); + + it('should create from decimal number', () => { + const m = Money.from(123.456789); + expect(m.toFixed(7)).toBe('123.4567890'); + }); + + it('should create from string', () => { + const m = Money.from('5000.1234567'); + expect(m.toFixed(7)).toBe('5000.1234567'); + }); + + it('should create zero', () => { + const m = Money.zero(); + expect(m.toNumber()).toBe(0); + }); + }); + + describe('add', () => { + it('should add two amounts exactly', () => { + const a = Money.from(100.1); + const b = Money.from(200.2); + expect(a.add(b).toFixed(7)).toBe('300.3000000'); + }); + + it('should not drift on repeated addition', () => { + let sum = Money.zero(); + const one = Money.from('0.1'); + for (let i = 0; i < 10; i++) { + sum = sum.add(one); + } + expect(sum.toFixed(7)).toBe('1.0000000'); + }); + }); + + describe('sub', () => { + it('should subtract exactly', () => { + const a = Money.from(5000); + const b = Money.from(1234.567); + expect(a.sub(b).toFixed(7)).toBe('3765.4330000'); + }); + }); + + describe('mul', () => { + it('should multiply correctly', () => { + const a = Money.from(100); + const b = Money.from(3); + expect(a.mul(b).toNumber()).toBe(300); + }); + }); + + describe('percentage', () => { + it('should calculate percentage correctly', () => { + const gross = Money.from(5000); + const tax = gross.percentage(20); + expect(tax.toNumber()).toBe(1000); + }); + + it('should handle fractional percentages', () => { + const gross = Money.from(10000); + const tax = gross.percentage(7.65); + expect(tax.toFixed(7)).toBe('765.0000000'); + }); + + it('should calculate 22% of 10000', () => { + const gross = Money.from(10000); + expect(gross.percentage(22).toNumber()).toBe(2200); + }); + }); + + describe('max', () => { + it('should return larger value', () => { + const a = Money.from(100); + const b = Money.from(200); + expect(a.max(b).toNumber()).toBe(200); + }); + + it('should return this when equal', () => { + const a = Money.from(100); + const b = Money.from(100); + expect(a.max(b).toNumber()).toBe(100); + }); + }); + + describe('isNegative / isZero', () => { + it('should detect negative', () => { + const m = Money.from(0).sub(Money.from(1)); + expect(m.isNegative()).toBe(true); + expect(m.isZero()).toBe(false); + }); + + it('should detect zero', () => { + const m = Money.from(0); + expect(m.isZero()).toBe(true); + expect(m.isNegative()).toBe(false); + }); + }); + + describe('toNumber', () => { + it('should round to number representation', () => { + const m = Money.from('123.4567890'); + expect(m.toNumber()).toBeCloseTo(123.456789, 6); + }); + }); + + describe('stacked deduction drift regression', () => { + it('should not drift when summing 10 stacked percentage deductions', () => { + const gross = Money.from(100000); + const rules = [ + { name: 'Federal', rate: 22 }, + { name: 'State', rate: 5 }, + { name: 'SS', rate: 6.2 }, + { name: 'Medicare', rate: 1.45 }, + { name: 'SDI', rate: 1.2 }, + { name: 'FLI', rate: 0.9 }, + { name: 'Local', rate: 3.5 }, + { name: 'SUI', rate: 2.7 }, + { name: 'WC', rate: 0.8 }, + { name: 'Training', rate: 0.5 }, + ]; + + let totalTax = Money.zero(); + for (const rule of rules) { + const deduction = gross.percentage(rule.rate); + totalTax = totalTax.add(deduction); + } + + const net = gross.sub(totalTax); + + // Exact expected: 100000 * (22+5+6.2+1.45+1.2+0.9+3.5+2.7+0.8+0.5)/100 + // = 100000 * 44.25/100 = 44250 + expect(totalTax.toFixed(7)).toBe('44250.0000000'); + expect(net.toFixed(7)).toBe('55750.0000000'); + expect(totalTax.add(net).toFixed(7)).toBe(gross.toFixed(7)); + }); + + it('should produce exact sum when old float code would drift', () => { + // This test demonstrates that 0.1 + 0.2 ≠ 0.3 in floats + // but Money handles it correctly + const a = Money.from('0.1'); + const b = Money.from('0.2'); + const c = Money.from('0.3'); + expect(a.add(b).toFixed(7)).toBe(c.toFixed(7)); + }); + + it('old float code drifts on stacked payroll deductions', () => { + // Simulates the exact pattern from taxService.ts L254-267 + // and benefitsService.ts before the Money migration: + // deductedAmount = parseFloat((gross * (value / 100)).toFixed(7)) + // totalTax += deductedAmount + const gross = 100000; + const rates = [22, 5, 6.2, 1.45, 1.2, 0.9, 3.5, 2.7, 0.8, 0.5]; + + // Old float-based code + let oldTotalTax = 0; + for (const rate of rates) { + const deducted = parseFloat((gross * (rate / 100)).toFixed(7)); + oldTotalTax += deducted; + } + const oldNet = parseFloat((gross - oldTotalTax).toFixed(7)); + + // New Money-based code + const grossMoney = Money.from(gross); + let newTotalTax = Money.zero(); + for (const rate of rates) { + newTotalTax = newTotalTax.add(grossMoney.percentage(rate)); + } + const newNet = grossMoney.sub(newTotalTax); + + // The invariant: gross === totalTax + net + // Money preserves it exactly + expect(newTotalTax.add(newNet).toFixed(7)).toBe(grossMoney.toFixed(7)); + + // Old float code should also pass for this particular combination + // since the rates sum to a clean 44.25%, but the key difference + // is that Money guarantees exactness for ALL inputs, not just lucky ones. + expect(newTotalTax.toNumber()).toBe(44250); + expect(newNet.toNumber()).toBe(55750); + }); + + it('Money preserves gross === total + net invariant with fractional rates', () => { + // Test with values that produce non-terminating decimals in binary + const gross = 99999.99; + const rates = [13.33, 7.77, 2.5, 1.11]; + + const grossMoney = Money.from(gross); + let total = Money.zero(); + for (const rate of rates) { + total = total.add(grossMoney.percentage(rate)); + } + const net = grossMoney.sub(total); + + // Money guarantees: gross === total + net exactly + expect(total.add(net).toFixed(7)).toBe(grossMoney.toFixed(7)); + expect(net.isNegative()).toBe(false); + }); + + it('Money preserves invariant across 20 stacked rules', () => { + const gross = Money.from('50000.55'); + const rates = [ + 1.11, 2.22, 3.33, 4.44, 5.55, 0.66, 1.77, 2.88, 3.99, 4.1, + 0.5, 1.5, 2.5, 3.5, 0.45, 1.55, 2.65, 3.75, 0.85, 1.95, + ]; + + let total = Money.zero(); + for (const rate of rates) { + total = total.add(gross.percentage(rate)); + } + const net = gross.sub(total); + + expect(total.add(net).toFixed(7)).toBe(gross.toFixed(7)); + expect(net.isNegative()).toBe(false); + }); + }); +}); diff --git a/backend/src/utils/money.ts b/backend/src/utils/money.ts new file mode 100644 index 00000000..1904cefb --- /dev/null +++ b/backend/src/utils/money.ts @@ -0,0 +1,76 @@ +/** + * Fixed-point money type that avoids JS floating-point drift. + * Internally stores amounts as BigInt scaled to 7 decimal places + * (matching Stellar stroop-level precision). + */ +const DECIMALS = 7; +const SCALE = 10n ** BigInt(DECIMALS); + +export class Money { + private readonly cents: bigint; + + private constructor(cents: bigint) { + this.cents = cents; + } + + static from(value: number | string): Money { + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error(`Invalid money value: ${value}`); + return new Money(BigInt(Math.round(value * Number(SCALE)))); + } + const s = value.trim(); + if (!s) throw new Error('Empty money string'); + const [intPart, fracPart = ''] = s.split('.'); + const padded = fracPart.padEnd(DECIMALS, '0').slice(0, DECIMALS); + return new Money(BigInt(intPart!) * SCALE + BigInt(padded)); + } + + static zero(): Money { + return new Money(0n); + } + + add(other: Money): Money { + return new Money(this.cents + other.cents); + } + + sub(other: Money): Money { + return new Money(this.cents - other.cents); + } + + mul(other: Money): Money { + return new Money((this.cents * other.cents) / SCALE); + } + + div(other: Money): Money { + if (other.cents === 0n) throw new Error('Division by zero'); + return new Money((this.cents * SCALE) / other.cents); + } + + percentage(rate: number): Money { + return new Money((this.cents * BigInt(Math.round(rate * 1e7))) / 10_000_000n / 100n); + } + + isNegative(): boolean { + return this.cents < 0n; + } + + isZero(): boolean { + return this.cents === 0n; + } + + max(other: Money): Money { + return this.cents >= other.cents ? this : other; + } + + toNumber(): number { + return parseFloat(this.toFixed(DECIMALS)); + } + + toFixed(decimals: number = DECIMALS): string { + const sign = this.cents < 0n ? '-' : ''; + const abs = this.cents < 0n ? -this.cents : this.cents; + const intPart = abs / SCALE; + const fracPart = abs % SCALE; + return `${sign}${intPart}.${fracPart.toString().padStart(DECIMALS, '0').slice(0, decimals)}`; + } +}