From 77305bb959433760b85b894e9f270a9d3cd28fcd Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 12:38:30 +0530 Subject: [PATCH 1/8] fix(workbook): show the decimals a value actually has Every numeric cell was written with a fixed `#,##0.00`, so a stored `0.001` displayed `0.00` and `1.234` displayed `1.23`. The exact value was in the cell and the sheet showed a different one, with nothing on the page saying so -- and a non-zero amount could read as zero on a working paper someone files from. Two mandatory decimal places keep the ordinary case looking like currency; the rest render only when the value has them, so `12.50` is unchanged while `0.001` shows in full. Widening the format alone would have moved the boundary rather than removed it, because significant digits do not bound decimal places: `0.0000000000000001` has one significant digit and sixteen decimals, and the old rule admitted it. So the writer's format and the caller's acceptance rule are now derived from one constant, `XLSX_NUMBER_DECIMAL_PLACES`, and a value with more decimals than a cell can display is refused rather than rounded silently -- taking the same `Precision limit` treatment an unrepresentable value already gets. Displayed and stored now agree by construction rather than by the data being well-behaved. Co-Authored-By: Claude Opus 5 --- .../gst/filed-returns-full-year-workbook.ts | 10 ++++++++ src/core/xlsx.ts | 24 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/connectors/gst/filed-returns-full-year-workbook.ts b/src/connectors/gst/filed-returns-full-year-workbook.ts index 5fe9f986..d904d1ff 100644 --- a/src/connectors/gst/filed-returns-full-year-workbook.ts +++ b/src/connectors/gst/filed-returns-full-year-workbook.ts @@ -1,4 +1,5 @@ import { isFlatJsonArrayCountReason } from "../../core/json-flat-table"; +import { XLSX_NUMBER_DECIMAL_PLACES } from "../../core/xlsx"; import { createXlsx, MAX_EXCEL_STRING_LENGTH, @@ -323,6 +324,15 @@ export function exactSpreadsheetNumber(input: string): number | null { .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. + const decimalPlaces = /\.(\d+)$/.exec(input)?.[1]?.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/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 { From 17a4407300cbcaa3b840d75a5b5c2962d48d4c52 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 12:38:30 +0530 Subject: [PATCH 2/8] test(workbook): pin the format shape and the decimal ceiling The rolled byte-identical digest carries its evidence in a comment: the regenerated workbook was unzipped and diffed against the previously validated one, and `xl/styles.xml` differed in exactly one attribute while every sheet, cell value and shared string stayed byte-identical. That diff is what shows totals, the `Precision limit` marker and the exact-decimal path are untouched -- a rolled digest asserts nothing by itself. Both halves mutation-checked. Reverting the format to two fixed places fails the format-shape test and the digest; dropping the decimal-place rejection fails the ceiling test alone. Co-Authored-By: Claude Opus 5 --- .../filed-returns-full-year-workbook.test.ts | 38 ++++++++++++++++++- tests/core/xlsx.test.ts | 23 ++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/tests/connectors/filed-returns-full-year-workbook.test.ts b/tests/connectors/filed-returns-full-year-workbook.test.ts index 68e19120..cdf02cf7 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, @@ -426,6 +430,29 @@ 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); + }); + 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 +474,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/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"), From 8a25fafa27f84e494868620a07ec63031c23f027 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 12:55:57 +0530 Subject: [PATCH 3/8] fix(workbook): count decimals after trailing zeros, not characters A zero past the last significant digit cannot change what a cell displays, so `1.23` and `1.2300000000000000` are the same value and must reach the same answer. Counting characters refused the second, and the refusal direction is the dangerous one: it reports as the guard working while withholding correct output. The blast radius was not one cell. `filed-returns-gstr2b-workbook.ts` treats a null from `exactSpreadsheetNumber` as unrepresentable and throws, so a single padded token anywhere in a year would have refused the entire GSTR-2B workbook rather than marking the value it came from. The tell was in the two lines: `significantDigits` directly above already strips trailing zeros, and the new count did not. One value, two treatments, inside one function. Co-Authored-By: Claude Opus 5 --- src/connectors/gst/filed-returns-full-year-workbook.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/connectors/gst/filed-returns-full-year-workbook.ts b/src/connectors/gst/filed-returns-full-year-workbook.ts index d904d1ff..4c7d8a0a 100644 --- a/src/connectors/gst/filed-returns-full-year-workbook.ts +++ b/src/connectors/gst/filed-returns-full-year-workbook.ts @@ -331,7 +331,15 @@ export function exactSpreadsheetNumber(input: string): number | null { // 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. - const decimalPlaces = /\.(\d+)$/.exec(input)?.[1]?.length ?? 0; + // + // 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; From 86e90900cf01b76ee01f185b35d8994340597e5f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 12:55:57 +0530 Subject: [PATCH 4/8] test(workbook): pin the padded token at the unit and at the artifact Two levels, because the unit answer and the consequence are different failures: `exactSpreadsheetNumber` must accept a padded value, and the GSTR-2B builder must still produce a workbook when a raw amount carries insignificant zeros. Reverting the trim fails exactly those two and nothing else. The artifact-level test asserts the workbook is not null before its length -- the defect refused it outright rather than producing a smaller one, and `tsc` caught that the original assertion would have passed through a null. Co-Authored-By: Claude Opus 5 --- .../filed-returns-full-year-workbook.test.ts | 13 +++++++++++ .../filed-returns-gstr2b-workbook.test.ts | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/connectors/filed-returns-full-year-workbook.test.ts b/tests/connectors/filed-returns-full-year-workbook.test.ts index cdf02cf7..f2f92ccc 100644 --- a/tests/connectors/filed-returns-full-year-workbook.test.ts +++ b/tests/connectors/filed-returns-full-year-workbook.test.ts @@ -453,6 +453,19 @@ describe("filed-return full-year workbook", () => { expect(exactSpreadsheetNumber("-2650.75")).toBe(-2650.75); }); + // 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 diff --git a/tests/connectors/filed-returns-gstr2b-workbook.test.ts b/tests/connectors/filed-returns-gstr2b-workbook.test.ts index 3bddd764..a0f2261d 100644 --- a/tests/connectors/filed-returns-gstr2b-workbook.test.ts +++ b/tests/connectors/filed-returns-gstr2b-workbook.test.ts @@ -400,6 +400,29 @@ describe("GSTR-2B consolidated workbook", () => { ).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 From b02260a25a766031c8b714bd28da30ca9a8255cd Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 13:22:25 +0530 Subject: [PATCH 5/8] fix(workbook): state the plain-decimal precondition instead of assuming it The decimal count reads the digits after the point, and an exponent token has none: `1.5e-20` counts zero decimals, passes every check, and then displays as `0.000000000000000`. Two spellings of one value would get two answers, and the one that slips through is the defect this function exists to prevent. Not reachable today, and that is the reason to pin it rather than to skip it. Every caller already converts -- the flattener runs `jsonNumberTokenToPlainDecimal` as it parses, and the GSTR-2B scan passes the converted form -- so the guard rejects nothing that occurs now. What it removes is an unstated precondition on an exported function, where the next caller's mistake would be a silent zero in a filed working paper rather than a visible refusal. Refused rather than converted, because converting here would put a second exponent parser beside the real one and the two would drift. Co-Authored-By: Claude Opus 5 --- .../gst/filed-returns-full-year-workbook.ts | 11 +++++++++++ .../filed-returns-full-year-workbook.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/connectors/gst/filed-returns-full-year-workbook.ts b/src/connectors/gst/filed-returns-full-year-workbook.ts index 4c7d8a0a..7b63615b 100644 --- a/src/connectors/gst/filed-returns-full-year-workbook.ts +++ b/src/connectors/gst/filed-returns-full-year-workbook.ts @@ -318,6 +318,17 @@ 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(".", "") diff --git a/tests/connectors/filed-returns-full-year-workbook.test.ts b/tests/connectors/filed-returns-full-year-workbook.test.ts index f2f92ccc..223d0563 100644 --- a/tests/connectors/filed-returns-full-year-workbook.test.ts +++ b/tests/connectors/filed-returns-full-year-workbook.test.ts @@ -453,6 +453,22 @@ describe("filed-return full-year workbook", () => { 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 From 70df253f07d5b264f0d6297aa8442c4ec65bed83 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 13:33:56 +0530 Subject: [PATCH 6/8] fix(gst): scan only the sections that become cells The precision scan covered the whole of `docdata`, so a value in a section this build does not recognise reached it. Those sections are excluded from the workbook and named in the coverage footer -- none of their values become cells -- and with the new decimal ceiling one such figure would refuse the entire artifact over a number that is never displayed. This is #204's shape again at one level down, and worth naming as such: that issue was "precision refusal rejects on values the workbook never renders", and scoping the scan to `docdata` and `itcsumm` fixed it for the siblings of those keys while leaving the same problem inside one of them. A tighter rule made the remaining gap reachable rather than creating it. The scan now descends into `SECTION_ORDER` -- the same list the sheets are built from, so a section cannot become renderable without the scan following it -- and `itcsumm` stays whole because it is rendered whole. Written as a table of how far to descend per subtree rather than a special case for `docdata`, so a third rendered subtree has to state its own answer. The fail-closed rule for a key the parser can reach and the scan cannot now applies at the section level too. Co-Authored-By: Claude Opus 5 --- .../gst/filed-returns-gstr2b-workbook.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) 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); + } } } From e1439f562857b62c156d0a7ba3c61d2dfd8cac31 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 13:33:56 +0530 Subject: [PATCH 7/8] test(gst): pin the excluded section and the retained guarantee Two cases, because the narrowing must not become a hole: an unrecognised section carrying an unrepresentable value still builds, and an unrepresentable value inside a rendered section is still refused. Restoring the whole-subtree scan fails the first and only the first. Co-Authored-By: Claude Opus 5 --- .../filed-returns-gstr2b-workbook.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/connectors/filed-returns-gstr2b-workbook.test.ts b/tests/connectors/filed-returns-gstr2b-workbook.test.ts index a0f2261d..8b28c847 100644 --- a/tests/connectors/filed-returns-gstr2b-workbook.test.ts +++ b/tests/connectors/filed-returns-gstr2b-workbook.test.ts @@ -400,6 +400,46 @@ 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 From 5adee3086159ef639f041d69ae1bef12a1fe0010 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 14:09:42 +0530 Subject: [PATCH 8/8] fix(workbook): let a month and its total agree on a small decimal `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` stringified as `1e-7`, the lexical comparison against the plain `exactText` failed, and the total reported itself unavailable at spreadsheet precision for a value the double holds exactly and the widened format now displays. The month showed the figure and the total said it could not. That is the same stored-versus-shown disagreement this branch exists to remove, one column over, and this change is what made it reachable: seven decimal places only became displayable here. Compared as plain decimals through `jsonNumberTokenToPlainDecimal` rather than as strings, so two spellings of one value stop giving two answers -- the same correction as the trailing-zero round, in the other direction. Co-Authored-By: Claude Opus 5 --- .../gst/filed-returns-full-year-workbook.ts | 18 ++++++++++-- .../filed-returns-full-year-workbook.test.ts | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/connectors/gst/filed-returns-full-year-workbook.ts b/src/connectors/gst/filed-returns-full-year-workbook.ts index 7b63615b..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,7 @@ -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, @@ -284,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 diff --git a/tests/connectors/filed-returns-full-year-workbook.test.ts b/tests/connectors/filed-returns-full-year-workbook.test.ts index 223d0563..04a0ae5c 100644 --- a/tests/connectors/filed-returns-full-year-workbook.test.ts +++ b/tests/connectors/filed-returns-full-year-workbook.test.ts @@ -317,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