Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions __tests__/audit_ledger_sum_checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
roundHalfEven,
divideWithRounding,
applyRoundedScale,
// Issue #498 – DB-column precision formatting
STANDARD_LEDGER_DB_SCHEMAS,
formatLedgerValueForDb,
validateLedgerFormatPrecision,
formatLedgerRowForDb,
} from "../src/utils/audit_ledger_sum_checker.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -352,3 +357,96 @@ describe("audit_ledger_sum_checker rounding policies", () => {
});
});
});

// ---------------------------------------------------------------------------
// Issue #498 – DB-column precision formatting
// ---------------------------------------------------------------------------

describe("audit_ledger_sum_checker DB-column formatting", () => {
describe("STANDARD_LEDGER_DB_SCHEMAS", () => {
it("stores amounts as exact TEXT within the digit limit", () => {
expect(STANDARD_LEDGER_DB_SCHEMAS.amount.format).toBe("TEXT");
expect(STANDARD_LEDGER_DB_SCHEMAS.amount.maxDigits).toBe(MAX_SAFE_DIGITS);
expect(STANDARD_LEDGER_DB_SCHEMAS.total.format).toBe("TEXT");
});
});

describe("formatLedgerValueForDb", () => {
it("renders plain integers with zero decimals", () => {
expect(formatLedgerValueForDb(123456789012345n)).toBe("123456789012345");
});

it("renders fixed-point decimals with zero padding", () => {
expect(formatLedgerValueForDb(10_000_000n, 7)).toBe("1.0000000");
expect(formatLedgerValueForDb(12_345_678n, 7)).toBe("1.2345678");
expect(formatLedgerValueForDb(1n, 7)).toBe("0.0000001");
});

it("preserves the sign of negative values", () => {
expect(formatLedgerValueForDb(-5_000_000n, 7)).toBe("-0.5000000");
});

it("rejects negative or fractional decimals", () => {
expect(() => formatLedgerValueForDb(1n, -1)).toThrow(RangeError);
expect(() => formatLedgerValueForDb(1n, 1.5)).toThrow(RangeError);
});
});

describe("validateLedgerFormatPrecision", () => {
it("confirms exact round-trips preserve full precision", () => {
expect(validateLedgerFormatPrecision(100n, "100").precisionLoss).toBe(false);
expect(validateLedgerFormatPrecision(10_000_000n, "1.0000000", 7).precisionLoss).toBe(false);
});

it("flags drifted values as precision loss", () => {
expect(validateLedgerFormatPrecision(100n, "101").precisionLoss).toBe(true);
expect(validateLedgerFormatPrecision(10_000_000n, "1.0000001", 7).precisionLoss).toBe(true);
});
});

describe("formatLedgerRowForDb", () => {
it("formats amount and total with full precision preserved", () => {
const result = formatLedgerRowForDb("100", "350", 2);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.row.amount).toBe("100");
expect(result.row.total).toBe("350");
expect(result.row.entry_index).toBe(2);
expect(result.row.precision_preserved).toBe(true);
expect(result.row.original_amount_bigint).toBe("100");
expect(result.row.original_total_bigint).toBe("350");
}
});

it("applies decimal scaling to both columns", () => {
const result = formatLedgerRowForDb(10_000_000n, 25_000_000n, 0, 7);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.row.amount).toBe("1.0000000");
expect(result.row.total).toBe("2.5000000");
}
});

it("rejects invalid amounts before formatting", () => {
const result = formatLedgerRowForDb("12.5", "10", 0);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(ERROR_CODES.INVALID_AMOUNT);
}
});

it("rejects excessive-digit totals", () => {
const excessive = "9".repeat(MAX_SAFE_DIGITS + 1);
const result = formatLedgerRowForDb("1", excessive, 0);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(ERROR_CODES.EXCESSIVE_DIGITS);
}
});

it("rejects negative entry indexes and decimals", () => {
expect(formatLedgerRowForDb("1", "1", -1).ok).toBe(false);
expect(formatLedgerRowForDb("1", "1", 0, -2).ok).toBe(false);
});
});
});
187 changes: 187 additions & 0 deletions src/utils/audit_ledger_sum_checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const ERROR_CODES = {
SUM_OVERFLOW: "OVERFLOW_SUM_EXCEEDED",
ROUNDING_INVALID_INPUT: "ROUNDING_INVALID_INPUT",
ROUNDING_SCALE_INVALID: "ROUNDING_SCALE_INVALID",
FORMAT_INVALID_DECIMALS: "FORMAT_INVALID_DECIMALS",
FORMAT_PRECISION_LOSS: "FORMAT_PRECISION_LOSS",
} as const;

export type OverflowErrorCode =
Expand Down Expand Up @@ -236,3 +238,188 @@ export function applyRoundedScale(

return roundHalfEven(product, denomCheck.value);
}

// ---------------------------------------------------------------------------
// TASK 5 – DB-column precision formatting (issue #498)
// ---------------------------------------------------------------------------

/**
* Database precision schema types for ledger columns.
* Mirrors `DbPrecisionFormat` in `partial-payment-allocator.ts` so ledger rows
* use the same storage vocabulary as the rest of the codebase.
*/
export type LedgerDbColumnFormat = "BIGINT" | "DECIMAL" | "TEXT";

export interface LedgerDbColumnSchema {
field: string;
format: LedgerDbColumnFormat;
maxDigits?: number;
nullable?: boolean;
}

/**
* Standard database precision schemas for ledger row fields.
* Amounts are stored as TEXT (exact bigint rendering, no float round-trip);
* counts/ordinals use BIGINT.
*/
export const STANDARD_LEDGER_DB_SCHEMAS: Record<string, LedgerDbColumnSchema> = {
amount: {
field: "amount",
format: "TEXT",
maxDigits: MAX_SAFE_DIGITS,
nullable: false,
},
total: {
field: "total",
format: "TEXT",
maxDigits: MAX_SAFE_DIGITS,
nullable: false,
},
entry_index: {
field: "entry_index",
format: "BIGINT",
nullable: false,
},
};

export interface FormattedLedgerRow {
amount: string;
total: string;
entry_index: number;
precision_preserved: boolean;
original_amount_bigint: string;
original_total_bigint: string;
}

export type LedgerFormatResult =
| { ok: true; row: FormattedLedgerRow }
| { ok: false; error: string; code: RoundingErrorCode | typeof ERROR_CODES.FORMAT_INVALID_DECIMALS | typeof ERROR_CODES.FORMAT_PRECISION_LOSS };

/**
* Format a bigint ledger `value` as a decimal string with exactly `decimals`
* fractional digits, suitable for a DECIMAL/TEXT column that downstream
* queries expect at fixed precision.
*
* `value` is the already-scaled integer representation; `decimals` restores
* the point (e.g. 7 decimals maps 10_000_000n to "1.0000000"). With
* `decimals = 0` the value renders as a plain integer string.
*/
export function formatLedgerValueForDb(value: bigint, decimals = 0): string {
if (typeof value !== "bigint") {
throw new TypeError("formatLedgerValueForDb: value must be a bigint");
}
if (!Number.isInteger(decimals) || decimals < 0) {
throw new RangeError(
`formatLedgerValueForDb: decimals must be a non-negative integer, got ${decimals}`
);
}

if (decimals === 0) {
return value.toString();
}

const isNegative = value < 0n;
const abs = isNegative ? -value : value;
const scale = 10n ** BigInt(decimals);
const integerPart = abs / scale;
const fractionalPart = abs % scale;
const fracStr = fractionalPart.toString().padStart(decimals, "0");
const formatted = `${integerPart.toString()}.${fracStr}`;
return isNegative ? `-${formatted}` : formatted;
}

/**
* Validate that a formatted ledger value round-trips to the original bigint
* (for `decimals = 0`) or to the correctly scaled representation, so a write
* never silently loses precision.
*/
export function validateLedgerFormatPrecision(
original: bigint,
formatted: string,
decimals = 0
): { ok: boolean; precisionLoss: boolean } {
try {
if (decimals === 0) {
const parsed = BigInt(formatted);
return { ok: true, precisionLoss: parsed !== original };
}
const expected = formatLedgerValueForDb(original, decimals);
return { ok: true, precisionLoss: formatted !== expected };
} catch {
return { ok: false, precisionLoss: true };
}
}

/**
* Format one ledger entry (amount + running total) for database storage.
* Both columns render through `formatLedgerValueForDb` and are checked for
* precision loss before the row is returned, so callers never write a row
* whose attributes drift from full precision.
*/
export function formatLedgerRowForDb(
amount: string | number | bigint,
total: string | number | bigint,
entryIndex: number,
decimals = 0
): LedgerFormatResult {
if (
typeof entryIndex !== "number" ||
!Number.isInteger(entryIndex) ||
entryIndex < 0
) {
return {
ok: false,
error: "entryIndex must be a non-negative integer",
code: ERROR_CODES.FORMAT_INVALID_DECIMALS,
};
}
if (!Number.isInteger(decimals) || decimals < 0) {
return {
ok: false,
error: `decimals must be a non-negative integer, got ${decimals}`,
code: ERROR_CODES.FORMAT_INVALID_DECIMALS,
};
}

const amountCheck = validateLedgerAmount(amount, "amount");
if (!amountCheck.ok) {
return amountCheck as LedgerFormatResult;
}
const totalCheck = validateLedgerAmount(total, "total");
if (!totalCheck.ok) {
return totalCheck as LedgerFormatResult;
}

const formattedAmount = formatLedgerValueForDb(amountCheck.value, decimals);
const formattedTotal = formatLedgerValueForDb(totalCheck.value, decimals);

const amountPrecision = validateLedgerFormatPrecision(
amountCheck.value,
formattedAmount,
decimals
);
const totalPrecision = validateLedgerFormatPrecision(
totalCheck.value,
formattedTotal,
decimals
);
if (!amountPrecision.ok || amountPrecision.precisionLoss || !totalPrecision.ok || totalPrecision.precisionLoss) {
return {
ok: false,
error: "precision loss detected while formatting ledger row for DB storage",
code: ERROR_CODES.FORMAT_PRECISION_LOSS,
};
}

return {
ok: true,
row: {
amount: formattedAmount,
total: formattedTotal,
entry_index: entryIndex,
precision_preserved: true,
original_amount_bigint: amountCheck.value.toString(),
original_total_bigint: totalCheck.value.toString(),
},
};
}
Loading