diff --git a/src/connectors/gst/filed-returns-full-year-workbook.ts b/src/connectors/gst/filed-returns-full-year-workbook.ts index 5fe9f986..bf592ca9 100644 --- a/src/connectors/gst/filed-returns-full-year-workbook.ts +++ b/src/connectors/gst/filed-returns-full-year-workbook.ts @@ -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, @@ -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 @@ -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; const value = Number(input); if (!Number.isFinite(value)) return null; if (value === 0 && !/^-?0+(?:\.0+)?$/.test(input)) return null; diff --git a/src/connectors/gst/filed-returns-gstr2b-workbook.ts b/src/connectors/gst/filed-returns-gstr2b-workbook.ts index d89815f2..55131b88 100644 --- a/src/connectors/gst/filed-returns-gstr2b-workbook.ts +++ b/src/connectors/gst/filed-returns-gstr2b-workbook.ts @@ -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> = { + docdata: SECTION_ORDER, + itcsumm: null, +}; /** * The value of `key` as a direct child of the object `text` describes. @@ -462,7 +478,7 @@ 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; @@ -470,7 +486,21 @@ function rejectInexactNumbersInRenderedSubtrees(text: string, parsed: unknown): "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); + } } } diff --git a/src/core/xlsx.ts b/src/core/xlsx.ts index 964e6c06..6a389606 100644 --- a/src/core/xlsx.ts +++ b/src/core/xlsx.ts @@ -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 { @@ -191,7 +213,7 @@ function workbookRelationshipsXml(sheetCount: number): string { } function stylesXml(): string { - return `${XML_HEADER}`; + return `${XML_HEADER}`; } function escapeXml(value: string): string { diff --git a/tests/connectors/filed-returns-full-year-workbook.test.ts b/tests/connectors/filed-returns-full-year-workbook.test.ts index 68e19120..04a0ae5c 100644 --- a/tests/connectors/filed-returns-full-year-workbook.test.ts +++ b/tests/connectors/filed-returns-full-year-workbook.test.ts @@ -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, @@ -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 @@ -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 @@ -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", ); }); diff --git a/tests/connectors/filed-returns-gstr2b-workbook.test.ts b/tests/connectors/filed-returns-gstr2b-workbook.test.ts index 3bddd764..8b28c847 100644 --- a/tests/connectors/filed-returns-gstr2b-workbook.test.ts +++ b/tests/connectors/filed-returns-gstr2b-workbook.test.ts @@ -400,6 +400,69 @@ describe("GSTR-2B consolidated workbook", () => { ).toThrow(/cannot be written to a spreadsheet without changing it/); }); + // A section this build does not recognise is excluded from the workbook and + // named in the coverage footer, so none of its values become cells. Scanning + // it for precision refused the whole artifact over a figure that is never + // displayed -- destroying a workbook correct in every rendered number. + it("builds when an unrendered section carries an unrepresentable value", () => { + const plan: FiledReturnsSummaryPlanEntry[] = [planEntry("April", "april-data.json")]; + const source = JSON.parse( + JSON.stringify({ + data: { gstin: OWNER_GSTIN, rtnprd: "042026", docdata: docdata() }, + }), + ) as { data: { docdata: Record } }; + // A future portal section, alongside the recognised ones. + source.data.docdata.futuresection = [{ amount: 0.0000000000000001 }]; + const bytes = new TextEncoder().encode(JSON.stringify(source)); + + const workbook = buildFiledReturnsGstr2bWorkbook(plan, [{ path: "april-data.json", bytes }], { + generatedAt: new Date("2026-08-22T12:00:00.000Z"), + }); + + expect(workbook).not.toBeNull(); + expect(workbook!.bytes.byteLength).toBeGreaterThan(0); + }); + + // The rendered sections are still scanned: this is a narrowing of scope, not + // of the guarantee. + it("still refuses an unrepresentable value inside a rendered section", () => { + const plan: FiledReturnsSummaryPlanEntry[] = [planEntry("April", "april-data.json")]; + const bytes = new TextEncoder().encode( + JSON.stringify({ + data: { gstin: OWNER_GSTIN, rtnprd: "042026", docdata: docdata() }, + }).replace('"val":110', '"val":0.0000000000000001'), + ); + + expect(() => + buildFiledReturnsGstr2bWorkbook(plan, [{ path: "april-data.json", bytes }], { + generatedAt: new Date("2026-08-22T12:00:00.000Z"), + }), + ).toThrow(/cannot be written to a spreadsheet without changing it/); + }); + + // A raw token padded with trailing zeros is the same value as its trimmed + // form and displays identically, so it must not be treated as unrepresentable. + // This path throws on that judgement rather than marking one cell, so a single + // padded amount anywhere in a year would have refused the entire workbook -- + // the whole artifact lost to a value that was never a problem. + it("builds when a raw amount is padded with insignificant trailing zeros", () => { + const plan: FiledReturnsSummaryPlanEntry[] = [planEntry("April", "april-data.json")]; + const bytes = new TextEncoder().encode( + JSON.stringify({ + data: { gstin: OWNER_GSTIN, rtnprd: "042026", docdata: docdata() }, + }).replace('"val":110', '"val":110.00000000000000000'), + ); + + const workbook = buildFiledReturnsGstr2bWorkbook(plan, [{ path: "april-data.json", bytes }], { + generatedAt: new Date("2026-08-22T12:00:00.000Z"), + }); + + // Not null is the assertion that matters: the defect refused the workbook + // outright rather than producing a smaller one. + expect(workbook).not.toBeNull(); + expect(workbook!.bytes.byteLength).toBeGreaterThan(0); + }); + // The scan compares raw key spelling; the parser decodes escapes. A canonical // source spelling `data` as `d\u0061ta` is therefore reachable by the parser // and invisible to the scan, and treating that as "nothing to check" renders diff --git a/tests/core/xlsx.test.ts b/tests/core/xlsx.test.ts index 22a23ef5..79ebe584 100644 --- a/tests/core/xlsx.test.ts +++ b/tests/core/xlsx.test.ts @@ -1,7 +1,28 @@ import { describe, expect, it } from "vitest"; -import { createXlsx, XlsxSizeLimitError } from "../../src/core/xlsx"; +import { createXlsx, XLSX_NUMBER_DECIMAL_PLACES, XlsxSizeLimitError } from "../../src/core/xlsx"; describe("portal-neutral XLSX writer", () => { + // The format fixed two decimal places, so a stored `0.001` displayed `0.00` + // and a non-zero amount read as zero on a working paper. Two mandatory places + // keep the ordinary case looking like currency; the rest render only when the + // value has them. + it("renders the decimals a value actually has", () => { + const entries = extractStoredZipEntries( + createXlsx({ + generatedAt: new Date("2026-08-19T12:00:00.000Z"), + worksheets: [{ name: "S", rows: [[{ value: 0.001, style: "number" as const }]] }], + }), + ); + + const format = /formatCode="([^"]*)"/.exec(text(entries, "xl/styles.xml"))?.[1] ?? ""; + + expect(format.startsWith("#,##0.00")).toBe(true); + // Mandatory places first, then optional ones -- an optional place before a + // mandatory one would drop the currency look from the common case. + expect(format).toMatch(/^#,##0\.0{2}#*$/); + expect(format.length - "#,##0.".length).toBe(XLSX_NUMBER_DECIMAL_PLACES); + }); + it("writes deterministic named worksheets with numeric, date, style and pane metadata", () => { const input = { generatedAt: new Date("2026-08-19T12:00:00.000Z"),