diff --git a/__tests__/tax_deduction_estimator.test.ts b/__tests__/tax_deduction_estimator.test.ts index 22e89ad..a0e7901 100644 --- a/__tests__/tax_deduction_estimator.test.ts +++ b/__tests__/tax_deduction_estimator.test.ts @@ -3,6 +3,11 @@ import { MAX_INTERMEDIATE_DIGITS, DEFAULT_TAX_SCALE, ERROR_CODES, + RoundingPolicy, + VALID_ROUNDING_POLICIES, + roundIntegerDivision, + verifyTaxDeductionRemainder, + validateTaxDeductionRemainder, validateTaxAmount, validateTaxRate, calculateTaxDeduction, @@ -397,4 +402,185 @@ describe("tax_deduction_estimator", () => { } }); }); + + describe("Issue #449: Configurable decimal rounding policies & remainder validation", () => { + describe("roundIntegerDivision unit tests", () => { + it("handles half-even (round-to-nearest-even / Banker's rounding) correctly", () => { + // 25 / 10 = 2.5 -> q=2 (even), rounds to 2 + expect(roundIntegerDivision(25n, 10n, "half-even")).toBe(2n); + expect(roundIntegerDivision(25n, 10n, "round-to-nearest-even")).toBe(2n); + + // 35 / 10 = 3.5 -> q=3 (odd), rounds to 4 + expect(roundIntegerDivision(35n, 10n, "half-even")).toBe(4n); + expect(roundIntegerDivision(35n, 10n, "round-to-nearest-even")).toBe(4n); + + // 10 / 3 = 3.333 -> rounds to 3 + expect(roundIntegerDivision(10n, 3n, "half-even")).toBe(3n); + + // 17 / 3 = 5.666 -> rounds to 6 + expect(roundIntegerDivision(17n, 3n, "half-even")).toBe(6n); + }); + + it("handles half-up rounding policy correctly", () => { + // 25 / 10 = 2.5 -> rounds up to 3 + expect(roundIntegerDivision(25n, 10n, "half-up")).toBe(3n); + + // 35 / 10 = 3.5 -> rounds up to 4 + expect(roundIntegerDivision(35n, 10n, "half-up")).toBe(4n); + + // 10 / 3 = 3.333 -> rounds to 3 + expect(roundIntegerDivision(10n, 3n, "half-up")).toBe(3n); + }); + + it("handles truncate / floor rounding policy correctly", () => { + // 25 / 10 = 2.5 -> truncates to 2 + expect(roundIntegerDivision(25n, 10n, "truncate")).toBe(2n); + expect(roundIntegerDivision(25n, 10n, "floor")).toBe(2n); + + // 35 / 10 = 3.5 -> truncates to 3 + expect(roundIntegerDivision(35n, 10n, "truncate")).toBe(3n); + + // 17 / 3 = 5.666 -> truncates to 5 + expect(roundIntegerDivision(17n, 3n, "truncate")).toBe(5n); + }); + + it("handles ceil rounding policy correctly", () => { + // 25 / 10 = 2.5 -> rounds up to 3 + expect(roundIntegerDivision(25n, 10n, "ceil")).toBe(3n); + + // 21 / 10 = 2.1 -> rounds up to 3 + expect(roundIntegerDivision(21n, 10n, "ceil")).toBe(3n); + + // 20 / 10 = 2.0 -> exact 2 + expect(roundIntegerDivision(20n, 10n, "ceil")).toBe(2n); + }); + }); + + describe("calculateTaxDeduction with configurable rounding policies", () => { + it("uses half-even (round-to-nearest-even) as default rounding policy", () => { + // Gross 10, rate 35, scale 100 -> product 350 / 100 = 3.5 -> rounds to 4 (q=3 is odd) + const outcome = calculateTaxDeduction(10, 35, 100); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.taxAmount).toBe(4n); + expect(outcome.netAmount).toBe(6n); + expect(outcome.remainder).toBe(50n); + expect(outcome.roundingMode).toBe("half-even"); + } + + // Gross 10, rate 25, scale 100 -> product 250 / 100 = 2.5 -> rounds to 2 (q=2 is even) + const outcomeEven = calculateTaxDeduction(10, 25, 100); + expect(outcomeEven.ok).toBe(true); + if (outcomeEven.ok) { + expect(outcomeEven.taxAmount).toBe(2n); + expect(outcomeEven.netAmount).toBe(8n); + expect(outcomeEven.remainder).toBe(50n); + } + }); + + it("supports explicit half-up rounding policy", () => { + // Gross 10, rate 25, scale 100 -> product 250 / 100 = 2.5 -> half-up rounds to 3 + const outcome = calculateTaxDeduction(10, 25, 100, "half-up"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.taxAmount).toBe(3n); + expect(outcome.netAmount).toBe(7n); + expect(outcome.roundingMode).toBe("half-up"); + } + }); + + it("supports explicit truncate rounding policy", () => { + // Gross 10, rate 35, scale 100 -> product 350 / 100 = 3.5 -> truncate rounds to 3 + const outcome = calculateTaxDeduction(10, 35, 100, "truncate"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.taxAmount).toBe(3n); + expect(outcome.netAmount).toBe(7n); + expect(outcome.roundingMode).toBe("truncate"); + } + }); + + it("supports explicit ceil rounding policy", () => { + // Gross 10, rate 21, scale 100 -> product 210 / 100 = 2.1 -> ceil rounds to 3 + const outcome = calculateTaxDeduction(10, 21, 100, "ceil"); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.taxAmount).toBe(3n); + expect(outcome.netAmount).toBe(7n); + expect(outcome.roundingMode).toBe("ceil"); + } + }); + + it("supports passing rounding policy inside an options object", () => { + const outcome = calculateTaxDeduction(10, 25, 100, { roundingMode: "half-up" }); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.taxAmount).toBe(3n); + expect(outcome.roundingMode).toBe("half-up"); + } + }); + + it("rejects invalid rounding mode with INVALID_ROUNDING_MODE error", () => { + const outcome = calculateTaxDeduction(100, 50, 1000, "invalid-policy" as unknown as RoundingPolicy); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe(ERROR_CODES.INVALID_ROUNDING_MODE); + expect(outcome.error).toMatch(/Invalid rounding mode/i); + } + }); + }); + + describe("verifyTaxDeductionRemainder & remainder preservation check", () => { + it("verifies that calculation results preserve exact division remainders and conserve amounts", () => { + const outcome = calculateTaxDeduction(100, 333, 10000); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + const check = verifyTaxDeductionRemainder(outcome); + expect(check.ok).toBe(true); + if (check.ok) { + expect(check.isValid).toBe(true); + expect(check.remainderPreserved).toBe(true); + expect(check.product).toBe(33300n); + expect(check.accounted).toBe(33300n); // (3 * 10000) + 3300 = 33300 + } + } + }); + + it("alias validateTaxDeductionRemainder produces identical result", () => { + const outcome = calculateTaxDeduction(500, 1250, 10000, "half-up"); + const check = validateTaxDeductionRemainder(outcome); + expect(check.ok).toBe(true); + if (check.ok) { + expect(check.isValid).toBe(true); + } + }); + }); + + describe("Integration with CSV export & DB formatting", () => { + it("applies configured rounding policy in buildTaxDeductionCsvBlock", () => { + // Gross 10, rate 25, scale 100 => product 250 / 100 = 2.5. With half-up -> taxAmount 3 + const csvRes = buildTaxDeductionCsvBlock( + [{ grossAmount: 10, taxRate: 25, taxScale: 100 }], + { roundingMode: "half-up" } + ); + expect(csvRes.ok).toBe(true); + if (csvRes.ok) { + expect(csvRes.value).toContain("10,25,3,7,50"); + } + }); + + it("applies configured rounding policy in formatForDbStorage", () => { + // Gross 10, taxRate 2500 (25%), default scale 10000 => product 25000 / 10000 = 2.5. With half-up -> taxAmount 3 + const dbRes = formatForDbStorage("10", "2500", 0, { + roundingMode: "half-up", + scale: 0, + }); + expect(dbRes.ok).toBe(true); + if (dbRes.ok) { + expect(dbRes.value.tax_amount).toBe("3"); + expect(dbRes.value.net_amount).toBe("7"); + } + }); + }); + }); }); diff --git a/src/utils/tax_deduction_estimator.ts b/src/utils/tax_deduction_estimator.ts index a8bcd12..b8e8ad4 100644 --- a/src/utils/tax_deduction_estimator.ts +++ b/src/utils/tax_deduction_estimator.ts @@ -84,6 +84,7 @@ export const ERROR_CODES = { RATE_EXCEEDS_SCALE: "TAX_ESTIMATOR_RATE_EXCEEDS_SCALE", EMPTY_BRACKETS: "TAX_ESTIMATOR_EMPTY_BRACKETS", INVALID_CSV_INPUT: "TAX_ESTIMATOR_INVALID_CSV_INPUT", + INVALID_ROUNDING_MODE: "TAX_ESTIMATOR_INVALID_ROUNDING_MODE", // Compatibility aliases OVERFLOW_EXCESSIVE_DIGITS: "OVERFLOW_EXCESSIVE_DIGITS", OVERFLOW_INVALID_AMOUNT: "OVERFLOW_INVALID_AMOUNT", @@ -97,6 +98,86 @@ export type ValidationResult = | { ok: true; value: bigint } | { ok: false; error: string; code: TaxEstimatorErrorCode; status?: number }; +export type RoundingPolicy = + | "half-even" + | "round-to-nearest-even" + | "half-up" + | "truncate" + | "ceil" + | "floor"; + +export const VALID_ROUNDING_POLICIES: RoundingPolicy[] = [ + "half-even", + "round-to-nearest-even", + "half-up", + "truncate", + "ceil", + "floor", +]; + +export interface CalculateTaxOptions { + roundingMode?: RoundingPolicy; + roundingPolicy?: RoundingPolicy; +} + +/** + * Perform deterministic integer division applying specified rounding policy on remainders. + * Default policy ("half-even" / "round-to-nearest-even") uses Banker's Rounding to break half ties to the nearest even integer. + */ +export function roundIntegerDivision( + numerator: bigint, + divisor: bigint, + mode: RoundingPolicy = "half-even" +): bigint { + let N = numerator; + let D = divisor; + + if (D < 0n) { + N = -N; + D = -D; + } + + const q = N / D; + const r = N % D; + + if (r === 0n) { + return q; + } + + const sign = N >= 0n ? 1n : -1n; + const absR = r >= 0n ? r : -r; + const twiceR = 2n * absR; + + if (mode === "truncate" || mode === "floor") { + return N < 0n ? (twiceR !== 0n ? q - 1n : q) : q; + } + + if (mode === "ceil") { + return N > 0n ? q + 1n : q; + } + + if (mode === "half-up") { + if (twiceR >= D) { + return q + sign; + } + return q; + } + + // mode is "half-even" or "round-to-nearest-even" (Banker's rounding) + if (twiceR < D) { + return q; + } else if (twiceR > D) { + return q + sign; + } else { + // Exact halfway tie + if (q % 2n === 0n) { + return q; + } else { + return q + sign; + } + } +} + export type TaxDeductionOutcome = | { ok: true; @@ -106,9 +187,42 @@ export type TaxDeductionOutcome = netAmount: bigint; remainder: bigint; taxScale: bigint; + roundingMode?: RoundingPolicy; } | { ok: false; error: string; code: TaxEstimatorErrorCode; status?: number }; +/** + * Validation check ensuring calculation results preserve exact division remainders and conserve amounts. + * Verifies that: + * 1. grossAmount * taxRate === (unroundedTax * taxScale) + remainder (remainder is not dropped or lost). + * 2. grossAmount === netAmount + taxAmount (conservation of funds). + */ +export function verifyTaxDeductionRemainder( + outcome: TaxDeductionOutcome +): + | { ok: true; isValid: boolean; product: bigint; accounted: bigint; remainderPreserved: boolean } + | { ok: false; error: string; code: TaxEstimatorErrorCode } { + if (!outcome.ok) { + return outcome; + } + const product = outcome.grossAmount * outcome.taxRate; + const unroundedTax = product / outcome.taxScale; + const accounted = unroundedTax * outcome.taxScale + outcome.remainder; + const remainderPreserved = product === accounted; + const isValid = remainderPreserved && outcome.grossAmount === outcome.netAmount + outcome.taxAmount; + + return { + ok: true, + isValid, + product, + accounted, + remainderPreserved, + }; +} + +/** Alias for verifyTaxDeductionRemainder */ +export const validateTaxDeductionRemainder = verifyTaxDeductionRemainder; + /** * Configuration options for database precision schema and column mapping. */ @@ -124,6 +238,8 @@ export interface DbPrecisionSchema { fixedScale?: boolean; /** Input type: auto, raw, or human. Defaults to "auto". */ inputType?: "auto" | "raw" | "human"; + /** Rounding policy applied during tax calculations. */ + roundingMode?: RoundingPolicy; /** Custom column names for database storage mapping. */ columns?: { grossAmount?: string; @@ -340,12 +456,36 @@ export function validateDbPrecisionSchema( /** * Calculate estimated withholding tax deduction for a given gross amount and tax rate. * Rejects calls when rate limit is exceeded or inputs overflow limits. + * Applies specified rounding policy (default: "half-even" / round-to-nearest-even). */ export function calculateTaxDeduction( grossAmount: string | number | bigint, taxRate: string | number | bigint, - taxScale: string | number | bigint = DEFAULT_TAX_SCALE + taxScale: string | number | bigint = DEFAULT_TAX_SCALE, + roundingModeOrOptions?: RoundingPolicy | CalculateTaxOptions ): TaxDeductionOutcome { + let roundingMode: RoundingPolicy = "half-even"; + + if (roundingModeOrOptions !== undefined && roundingModeOrOptions !== null) { + if (typeof roundingModeOrOptions === "string") { + roundingMode = roundingModeOrOptions; + } else if (typeof roundingModeOrOptions === "object") { + if (roundingModeOrOptions.roundingMode !== undefined) { + roundingMode = roundingModeOrOptions.roundingMode; + } else if (roundingModeOrOptions.roundingPolicy !== undefined) { + roundingMode = roundingModeOrOptions.roundingPolicy; + } + } + } + + if (!VALID_ROUNDING_POLICIES.includes(roundingMode)) { + return { + ok: false, + error: `Invalid rounding mode: ${String(roundingMode)}`, + code: ERROR_CODES.INVALID_ROUNDING_MODE, + }; + } + const rateLimitCheck = checkTaxEstimatorRateLimit(); if (!rateLimitCheck.ok) { return rateLimitCheck; @@ -391,7 +531,7 @@ export function calculateTaxDeduction( }; } - const taxAmount = product / scaleRes.value; + const taxAmount = roundIntegerDivision(product, scaleRes.value, roundingMode); const remainder = product % scaleRes.value; const netAmount = grossRes.value - taxAmount; @@ -411,6 +551,7 @@ export function calculateTaxDeduction( netAmount, remainder, taxScale: scaleRes.value, + roundingMode, }; } @@ -461,7 +602,7 @@ export function formatForDbStorage( } outcome = amount; } else { - outcome = calculateTaxDeduction(amount, taxRate); + outcome = calculateTaxDeduction(amount, taxRate, DEFAULT_TAX_SCALE, schema?.roundingMode); if (!outcome.ok) { return outcome; } @@ -570,6 +711,7 @@ export interface TaxDeductionCsvRecord { taxRate: string | number | bigint; taxScale?: string | number | bigint; label?: string; + roundingMode?: RoundingPolicy; } /** Columns the tax deduction CSV exporter can emit. */ @@ -602,6 +744,8 @@ export interface TaxCsvExportOptions { scale?: number; /** Whether an empty records array is allowed. Defaults to true. */ allowEmpty?: boolean; + /** Rounding policy applied during tax calculations. */ + roundingMode?: RoundingPolicy; } export type TaxCsvOutcome = @@ -702,7 +846,8 @@ export function buildTaxDeductionCsvBlock( const outcome = calculateTaxDeduction( record.grossAmount, record.taxRate, - record.taxScale ?? DEFAULT_TAX_SCALE + record.taxScale ?? DEFAULT_TAX_SCALE, + record.roundingMode ?? options.roundingMode ); if (!outcome.ok) { return { ...outcome, error: `record at index ${i}: ${outcome.error}` }; @@ -749,6 +894,7 @@ export interface TaxBracket { upTo: string | number | bigint | null | undefined; rate: string | number | bigint; scale?: string | number | bigint; + roundingMode?: RoundingPolicy; } export type BracketTaxOutcome = @@ -759,6 +905,7 @@ export type BracketTaxOutcome = totalTaxAmount: bigint; netAmount: bigint; effectiveRateBps: bigint; + roundingMode?: RoundingPolicy; } | { ok: false; error: string; code: TaxEstimatorErrorCode; status?: number }; @@ -798,7 +945,7 @@ function validateBracket( bracket: TaxBracket, index: number ): - | { ok: true; upTo: bigint | null; rate: bigint; scale: bigint } + | { ok: true; upTo: bigint | null; rate: bigint; scale: bigint; roundingMode?: RoundingPolicy } | { ok: false; error: string; code: TaxEstimatorErrorCode } { const label = `brackets[${index}]`; @@ -831,8 +978,17 @@ function validateBracket( }; } + const mode = bracket.roundingMode; + if (mode !== undefined && !VALID_ROUNDING_POLICIES.includes(mode)) { + return { + ok: false, + error: `Invalid rounding mode for ${label}: ${String(mode)}`, + code: ERROR_CODES.INVALID_ROUNDING_MODE, + }; + } + if (bracket.upTo === null || bracket.upTo === undefined) { - return { ok: true, upTo: null, rate: rateCheck.value, scale: scaleCheck.value }; + return { ok: true, upTo: null, rate: rateCheck.value, scale: scaleCheck.value, roundingMode: mode }; } const upToCheck = validateTaxAmount(bracket.upTo, `${label}.upTo`); @@ -847,6 +1003,7 @@ function validateBracket( upTo: upToCheck.value, rate: rateCheck.value, scale: scaleCheck.value, + roundingMode: mode, }; } @@ -862,8 +1019,27 @@ function validateBracket( */ export function estimateBracketTax( grossAmount: string | number | bigint, - brackets: TaxBracket[] + brackets: TaxBracket[], + roundingModeOrOptions?: RoundingPolicy | { roundingMode?: RoundingPolicy } ): BracketTaxOutcome { + let roundingMode: RoundingPolicy = "half-even"; + + if (roundingModeOrOptions !== undefined && roundingModeOrOptions !== null) { + if (typeof roundingModeOrOptions === "string") { + roundingMode = roundingModeOrOptions; + } else if (typeof roundingModeOrOptions === "object" && roundingModeOrOptions.roundingMode !== undefined) { + roundingMode = roundingModeOrOptions.roundingMode; + } + } + + if (!VALID_ROUNDING_POLICIES.includes(roundingMode)) { + return { + ok: false, + error: `Invalid rounding mode: ${String(roundingMode)}`, + code: ERROR_CODES.INVALID_ROUNDING_MODE, + }; + } + const rateLimitCheck = checkTaxEstimatorRateLimit(); if (!rateLimitCheck.ok) { return rateLimitCheck; @@ -883,7 +1059,7 @@ export function estimateBracketTax( } const gross = grossCheck.value; - const resolved: { upTo: bigint | null; rate: bigint; scale: bigint }[] = []; + const resolved: { upTo: bigint | null; rate: bigint; scale: bigint; roundingMode?: RoundingPolicy }[] = []; for (let i = 0; i < brackets.length; i++) { const bracketResult = validateBracket(brackets[i], i); @@ -894,6 +1070,7 @@ export function estimateBracketTax( upTo: bracketResult.upTo, rate: bracketResult.rate, scale: bracketResult.scale, + roundingMode: bracketResult.roundingMode, }); } @@ -924,7 +1101,7 @@ export function estimateBracketTax( let lowerBound = 0n; for (let i = 0; i < resolved.length; i++) { - const { upTo, rate, scale } = resolved[i]; + const { upTo, rate, scale, roundingMode: bracketRounding } = resolved[i]; if (remaining <= 0n) { bracketTaxes.push(0n); @@ -943,7 +1120,8 @@ export function estimateBracketTax( }; } - const bracketTax = product / scale; + const modeToUse = bracketRounding ?? roundingMode; + const bracketTax = roundIntegerDivision(product, scale, modeToUse); bracketTaxes.push(bracketTax); totalTaxAmount += bracketTax; remaining -= slice; @@ -967,5 +1145,6 @@ export function estimateBracketTax( totalTaxAmount, netAmount: gross - totalTaxAmount, effectiveRateBps: gross > 0n ? (totalTaxAmount * 10_000n) / gross : 0n, + roundingMode, }; }