Skip to content
47 changes: 45 additions & 2 deletions src/connectors/gst/filed-returns-full-year-workbook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { isFlatJsonArrayCountReason } from "../../core/json-flat-table";
import {
isFlatJsonArrayCountReason,
jsonNumberTokenToPlainDecimal,
} from "../../core/json-flat-table";
import { XLSX_NUMBER_DECIMAL_PLACES } from "../../core/xlsx";
import {
createXlsx,
MAX_EXCEL_STRING_LENGTH,
Expand Down Expand Up @@ -283,7 +287,18 @@ function exactTotalSpreadsheetValue(
const exactText = exactDecimalSum(inputs);
if (exactText === null) return "Exact total unavailable: invalid source decimal";
const value = exactSpreadsheetNumber(exactText);
if (hasUnrepresentableMonth || value === null || String(value) !== exactText) {
// `String(value)` is the shortest decimal that round-trips to the same double,
// and below `1e-6` JavaScript writes that in exponent form -- so `0.0000001`
// stringifies as `1e-7` and the lexical comparison failed for a value the
// double represents exactly. The monthly cell displayed it and the total said
// it was unavailable at spreadsheet precision, which is the same disagreement
// between stored and shown that this change set out to remove, one column
// over.
//
// Compared as plain decimals through the canonical converter rather than as
// strings, so two spellings of one value stop giving two answers.
const roundTrip = value === null ? null : jsonNumberTokenToPlainDecimal(String(value));
if (hasUnrepresentableMonth || value === null || roundTrip !== exactText) {
const explanatoryTotal = `Exact total ${exactText} unavailable at spreadsheet numeric precision`;
return explanatoryTotal.length <= MAX_EXCEL_STRING_LENGTH
? explanatoryTotal
Expand Down Expand Up @@ -317,12 +332,40 @@ function exactDecimalSum(inputs: readonly string[]): string | null {
* already happened.
*/
export function exactSpreadsheetNumber(input: string): number | null {
// Plain decimal only, stated rather than assumed. Every caller already
// converts -- the flattener runs `jsonNumberTokenToPlainDecimal` as it parses,
// and the GSTR-2B scan passes the converted form -- so this rejects nothing
// reachable today. It is here because the decimal count below reads the digits
// after the point, and an exponent token has none: `1.5e-20` would count zero
// decimals, pass every check, and then display as `0.000000000000000`. That is
// the defect this function exists to prevent, waved through by a spelling.
//
// Refused rather than converted, because converting here would put a second
// exponent parser beside the real one and the two would drift.
if (!/^-?\d+(?:\.\d+)?$/.test(input)) return null;
const significantDigits = input
.replace(/^-/, "")
.replace(".", "")
.replace(/^0+/, "")
.replace(/0+$/, "").length;
if (significantDigits > 15) return null;
// Significant digits do not bound decimal places: `0.0000000000000001` has one
// significant digit and sixteen decimals. The cell format renders
// `XLSX_NUMBER_DECIMAL_PLACES` of them, so a value with more cannot be
// displayed as what it is, and returning it here would store one number and
// show another -- the defect this rule exists to prevent, surviving past a
// wider format. It takes the `Precision limit` treatment instead, which is
// what an unrepresentable value already gets.
//
// Counted after trailing zeros, like `significantDigits` above it. A zero past
// the last significant digit cannot change what is displayed, so `1.23` and
// `1.2300000000000000` are the same value and must reach the same answer;
// counting characters instead refused the second. The cost of that was not
// confined to one cell -- `filed-returns-gstr2b-workbook.ts` treats a null
// here as unrepresentable and throws, so a single padded token would have
// refused an entire GSTR-2B year.
const decimalPlaces = /\.(\d+)$/.exec(input)?.[1]?.replace(/0+$/, "").length ?? 0;
if (decimalPlaces > XLSX_NUMBER_DECIMAL_PLACES) return null;
Comment thread
lamemustafa marked this conversation as resolved.
Comment thread
lamemustafa marked this conversation as resolved.
const value = Number(input);
if (!Number.isFinite(value)) return null;
if (value === 0 && !/^-?0+(?:\.0+)?$/.test(input)) return null;
Expand Down
36 changes: 33 additions & 3 deletions src/connectors/gst/filed-returns-gstr2b-workbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,23 @@ interface OwnerIdentity {
* It was not when this scan was written, which is how a whole-document sweep
* looked harmless.
*/
const RENDERED_SUBTREE_KEYS = ["docdata", "itcsumm"] as const;
/**
* What the precision scan covers, and how far into each subtree it descends.
*
* `null` means the whole subtree is rendered. A key list means only those
* children become cells: `docdata` carries one entry per portal section, and a
* section this build does not recognise is excluded from the workbook and named
* in the coverage footer instead. Its values never become cells, so their
* precision cannot change anything displayed, and refusing the workbook over one
* would destroy an artifact that is correct in every rendered figure.
*
* Driven off `SECTION_ORDER`, the same list the sheets are built from, so a
* section cannot become renderable without the scan following it.
*/
const RENDERED_SUBTREES: Readonly<Record<string, readonly string[] | null>> = {
docdata: SECTION_ORDER,
itcsumm: null,
Comment thread
lamemustafa marked this conversation as resolved.
};

/**
* The value of `key` as a direct child of the object `text` describes.
Expand Down Expand Up @@ -462,15 +478,29 @@ function rejectInexactNumbersInRenderedSubtrees(text: string, parsed: unknown):
"GSTR-2B workbook source spells a rendered key in a form this build cannot scan for exact amounts.",
);
}
for (const key of RENDERED_SUBTREE_KEYS) {
for (const [key, renderedChildren] of Object.entries(RENDERED_SUBTREES)) {
const subtree = childValueText(data, key);
if (subtree === undefined) {
if (childObject(parsedData, key) === undefined) continue;
throw new FiledReturnsGstr2bWorkbookSchemaError(
"GSTR-2B workbook source spells a rendered key in a form this build cannot scan for exact amounts.",
);
}
rejectInexactNumbers(subtree);
if (renderedChildren === null) {
rejectInexactNumbers(subtree);
continue;
}
const parsedSubtree = childObject(parsedData, key);
for (const child of renderedChildren) {
const childText = childValueText(subtree, child);
if (childText === undefined) {
if (childObject(parsedSubtree, child) === undefined) continue;
throw new FiledReturnsGstr2bWorkbookSchemaError(
"GSTR-2B workbook source spells a rendered key in a form this build cannot scan for exact amounts.",
);
}
rejectInexactNumbers(childText);
}
}
}

Expand Down
24 changes: 23 additions & 1 deletion src/core/xlsx.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { createZip, type ZipEntry } from "./zip";

/**
* How many decimal places a numeric cell renders.
*
* The format previously fixed two, so a stored `0.001` displayed `0.00` and a
* non-zero amount could read as zero on a working paper someone files from.
* Displayed and stored disagreeing is the worst class of defect in this
* artifact, because nothing on the page says it is happening.
*
* Two mandatory places keep the ordinary case looking like currency, and the
* rest appear only when the value has them -- `#` renders a digit if present
* and nothing if not, so `12.50` stays `12.50` while `0.001` shows in full.
*
* Exported because the writer's format and the caller's acceptance rule have to
* mean the same thing. A value with more decimal places than this cannot be
* displayed faithfully, so the caller must refuse it rather than let it round
* silently; `exactSpreadsheetNumber` reads this constant to decide. Widening the
* format alone would only move the boundary and leave the same defect past it.
*/
export const XLSX_NUMBER_DECIMAL_PLACES = 15;

const XLSX_NUMBER_FORMAT_CODE = `#,##0.${"0".repeat(2)}${"#".repeat(XLSX_NUMBER_DECIMAL_PLACES - 2)}`;

export type XlsxCellStyle = "bold" | "number" | "date" | "bold-date";

export interface XlsxCell {
Expand Down Expand Up @@ -191,7 +213,7 @@ function workbookRelationshipsXml(sheetCount: number): string {
}

function stylesXml(): string {
return `${XML_HEADER}<styleSheet xmlns="${SPREADSHEET_NS}"><numFmts count="2"><numFmt numFmtId="164" formatCode="#,##0.00"/><numFmt numFmtId="165" formatCode="mmm"/></numFmts><fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font><font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="right"/></xf><xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="center"/></xf><xf numFmtId="165" fontId="1" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyFont="1"><alignment horizontal="center"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
return `${XML_HEADER}<styleSheet xmlns="${SPREADSHEET_NS}"><numFmts count="2"><numFmt numFmtId="164" formatCode="${XLSX_NUMBER_FORMAT_CODE}"/><numFmt numFmtId="165" formatCode="mmm"/></numFmts><fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font><font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="right"/></xf><xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"><alignment horizontal="center"/></xf><xf numFmtId="165" fontId="1" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyFont="1"><alignment horizontal="center"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
}

function escapeXml(value: string): string {
Expand Down
96 changes: 94 additions & 2 deletions tests/connectors/filed-returns-full-year-workbook.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { buildFiledReturnsFullYearWorkbook } from "../../src/connectors/gst/filed-returns-full-year-workbook";
import {
buildFiledReturnsFullYearWorkbook,
exactSpreadsheetNumber,
} from "../../src/connectors/gst/filed-returns-full-year-workbook";
import { XLSX_NUMBER_DECIMAL_PLACES } from "../../src/core/xlsx";
import {
filedReturnsStatementCoverage,
filedReturnsStatementLineItems,
Expand Down Expand Up @@ -313,6 +317,35 @@ describe("filed-return full-year workbook", () => {
expect(numbers).not.toContain("1");
});

// `String(value)` is the shortest decimal that round-trips to the same double,
// and below `1e-6` JavaScript writes it in exponent form -- so `0.0000001`
// stringified as `1e-7` and the lexical comparison failed for a value the
// double holds exactly. The month displayed the figure while the total called
// it unavailable at spreadsheet precision: the same stored-versus-shown
// disagreement this change removes, one column over.
it("totals a small decimal the widened format now displays", () => {
const plan = fullYearPlan();
const summary = buildFiledReturnsSummarySheet(plan, [
{
path: "april-data.json",
bytes: new TextEncoder().encode(
'{"status":1,"data":{"lglnm":"Synthetic Legal Name","r3b":{"gstin":"27ABCDE1234F1Z0","ret_period":"042026","sup_details":{"osup_det":{"txval":0.0000001}}}}}',
),
},
]);
const workbook = buildFiledReturnsFullYearWorkbook(summary, plan, {
generatedAt: new Date("2026-08-19T12:00:00.000Z"),
});
const rows = parsedRows(text(extractStoredZipEntries(workbook), "xl/worksheets/sheet1.xml"));
const cells = [...rows.values()].flatMap((row) => [...row.values()]);
const numbers = cells.map((cell) => cell.number ?? "");
const texts = cells.map((cell) => cell.text ?? "");

// The month and its total are the same figure, so they must agree.
expect(numbers.filter((number) => Number(number) === 1e-7)).toHaveLength(2);
expect(texts.join(" ")).not.toContain("unavailable at spreadsheet numeric precision");
});

it("refuses a total when a month is a numeric-looking string", () => {
const plan = fullYearPlan();
// "100" parses as a decimal, so it was summed into the total while its own
Expand Down Expand Up @@ -426,6 +459,58 @@ describe("filed-return full-year workbook", () => {
expect(taxableValueRow?.get("N7")).toMatchObject({ number: 11, style: "2" });
});

// Significant digits do not bound decimal places: `0.0000000000000001` has one
// significant digit and sixteen decimals, so the old rule admitted it. The
// cell format cannot render that, and a stored value the sheet displays as
// zero is the exact defect the wider format was meant to remove -- surviving
// past the new boundary rather than at the old one. It takes the same
// `Precision limit` treatment an unrepresentable value already gets.
it("refuses a value with more decimals than a cell can display", () => {
const withinFormat = `0.${"0".repeat(XLSX_NUMBER_DECIMAL_PLACES - 1)}1`;
const beyondFormat = `0.${"0".repeat(XLSX_NUMBER_DECIMAL_PLACES)}1`;

expect(withinFormat.split(".")[1]).toHaveLength(XLSX_NUMBER_DECIMAL_PLACES);
expect(exactSpreadsheetNumber(withinFormat)).not.toBeNull();
expect(exactSpreadsheetNumber(beyondFormat)).toBeNull();
});

// The ordinary case must not be caught by the new rule.
it("keeps admitting the decimals a portal amount actually carries", () => {
expect(exactSpreadsheetNumber("12.50")).toBe(12.5);
expect(exactSpreadsheetNumber("0.001")).toBe(0.001);
expect(exactSpreadsheetNumber("1.234")).toBe(1.234);
expect(exactSpreadsheetNumber("-2650.75")).toBe(-2650.75);
});

// The decimal count reads digits after the point, and an exponent token has
// none -- `1.5e-20` counts zero decimals, passes every check, and displays as
// `0.000000000000000`. Two spellings of one value must not get two answers.
//
// Not reachable today: the flattener converts as it parses and the GSTR-2B
// scan passes the converted form. Pinned because the precondition is now
// stated rather than assumed, and an assumption nothing tests is how the next
// caller reintroduces this.
it("refuses an exponent token rather than reading zero decimals from it", () => {
expect(exactSpreadsheetNumber("1.5e-20")).toBeNull();
expect(exactSpreadsheetNumber("1e-16")).toBeNull();
expect(exactSpreadsheetNumber("1.5E+3")).toBeNull();
// The plain spelling of the same magnitude still answers as before.
expect(exactSpreadsheetNumber("1500")).toBe(1500);
});

// A zero past the last significant digit cannot change what is displayed, so
// a padded token is the same value as its trimmed form and must reach the same
// answer. Counting characters refused it -- and because the GSTR-2B workbook
// treats a null here as unrepresentable and throws, one padded token would
// have refused an entire year rather than marking one cell.
it("ignores trailing zeros that cannot change the displayed value", () => {
const padded = `1.23${"0".repeat(XLSX_NUMBER_DECIMAL_PLACES)}`;

expect(padded.split(".")[1]!.length).toBeGreaterThan(XLSX_NUMBER_DECIMAL_PLACES);
expect(exactSpreadsheetNumber(padded)).toBe(1.23);
expect(exactSpreadsheetNumber(`0.${"0".repeat(XLSX_NUMBER_DECIMAL_PLACES + 4)}`)).toBe(0);
});

it("keeps a fully filed workbook byte-identical", () => {
// The digest is only stable because the suite pins TZ=UTC. ZIP entry headers
// carry a DOS date built from local-time getters, so this assertion silently
Expand All @@ -447,8 +532,15 @@ describe("filed-return full-year workbook", () => {
generatedAt: new Date("2026-08-20T12:00:00.000Z"),
});

// Rolled once, for the number format widening in #167. The regenerated
// workbook was unzipped and diffed against the previously validated one
// entry by entry: `xl/styles.xml` differed in exactly one attribute,
// `formatCode="#,##0.00"` becoming `"#,##0.00#############"`, and every
// sheet, cell value and shared string was byte-identical. That diff is the
// evidence that totals, the `Precision limit` marker and the exact-decimal
// path are untouched -- a rolled digest asserts nothing on its own.
expect(createHash("sha256").update(workbook).digest("hex")).toBe(
"3c7b76fc3cc8fae35f88632c1e08942c1842af6776f24eb056054f3259fbdaf6",
"fedc3860070cd4fd3d66190090669b56a70442bb2111a355533c2c597429cf3d",
);
});

Expand Down
Loading