From 653483044d1778beca4dad03fcd6cf0494315d90 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 15:16:24 +0530 Subject: [PATCH 1/3] fix(summary): identify CSV financial year Put financial_year first so it pairs naturally with period for human readers. Repeat the plan-validated single value on every data row: row-level CSV extraction and recombination retain scope without introducing a non-tabular preamble. Do not change the context header: context rows are transient identity-bearing workbook input, not an emitted artifact, so adding scope there would widen an internal sensitive contract without helping a CSV consumer. A per-row column is preferable to file metadata because the CSV is a fixed tidy table and its rows are consumed independently. The plan must contain exactly one financial year before rows are built. Existing toCsv serialization continues to apply the same CSV/formula screening as adjacent fields. --- .../gst/filed-returns-summary-sheet.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/connectors/gst/filed-returns-summary-sheet.ts b/src/connectors/gst/filed-returns-summary-sheet.ts index 455fe317..0bbc6e63 100644 --- a/src/connectors/gst/filed-returns-summary-sheet.ts +++ b/src/connectors/gst/filed-returns-summary-sheet.ts @@ -35,6 +35,7 @@ export const MAX_FILED_RETURNS_SUMMARY_ROWS = 100_000; export const MAX_FILED_RETURNS_SUMMARY_ARRAY_EXPANSION_ELEMENTS = 64; export const FILED_RETURNS_SUMMARY_HEADERS = [ + "financial_year", "period", "return_type", "artifact", @@ -82,6 +83,7 @@ export interface FiledReturnsSummaryDataRow { artifact: FiledReturnsConcreteArtifactType; fieldLabel: string; fieldPath: string; + financialYear: string; outcome: string; period: FiledReturnsMonth; returnType: FiledReturnsReturnType; @@ -151,6 +153,7 @@ export function buildFiledReturnsSummarySheet( maxOutputBytes = Number.POSITIVE_INFINITY, ): FiledReturnsSummarySheet { const sortedPlan = [...plan].sort(comparePlanEntries); + const financialYear = summaryFinancialYear(sortedPlan); const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); const parsedPeriods = new Set(); let remainingFlattenedBytes = maxOutputBytes; @@ -212,11 +215,12 @@ export function buildFiledReturnsSummarySheet( !(leaf.valueKind === "text" && isCredentialShapedValue(leaf.value)), ); if (fieldLeaves.length === 0) { - dataRows.push(outcomeRow(parsed.planned, parsed.outcome)); + dataRows.push(outcomeRow(parsed.planned, financialYear, parsed.outcome)); continue; } for (const leaf of fieldLeaves) { dataRows.push({ + financialYear, period: parsed.planned.period, returnType: parsed.planned.returnType, artifact: parsed.planned.artifactType, @@ -240,7 +244,7 @@ export function buildFiledReturnsSummarySheet( maxUtf8Bytes: maxOutputBytes, }); const dataBytes = new TextEncoder().encode(dataCsv); - const contextRows = buildContextRows(sortedPlan, identities); + const contextRows = buildContextRows(identities); return { contextRows, dataBytes, @@ -487,9 +491,11 @@ export function isValidGstin(value: string): boolean { function outcomeRow( planned: FiledReturnsSummaryPlanEntry, + financialYear: string, outcome: string, ): FiledReturnsSummaryDataRow { return { + financialYear, period: planned.period, returnType: planned.returnType, artifact: planned.artifactType, @@ -500,13 +506,8 @@ function outcomeRow( } function buildContextRows( - plan: readonly FiledReturnsSummaryPlanEntry[], identityValues: readonly SummaryIdentityValue[], ): FiledReturnsSummaryContextRow[] { - const financialYears = sortedUnique(plan.map((entry) => entry.financialYear)); - if (financialYears.length !== 1) { - throw new SyntaxError("Filed-return summary plan must have one financial year."); - } const identities = [...identityValues] .sort( (left, right) => @@ -524,8 +525,17 @@ function buildContextRows( return identities; } +function summaryFinancialYear(plan: readonly FiledReturnsSummaryPlanEntry[]): string { + const financialYears = sortedUnique(plan.map((entry) => entry.financialYear)); + if (financialYears.length !== 1) { + throw new SyntaxError("Filed-return summary plan must have one financial year."); + } + return financialYears[0]!; +} + function dataCsvRow(row: FiledReturnsSummaryDataRow): Record { return { + financial_year: row.financialYear, period: row.period, return_type: row.returnType, artifact: row.artifact, From a136c2783a137047bedede52940eca8ee6edd57b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 15:16:29 +0530 Subject: [PATCH 2/3] test(summary): cover CSV financial year --- .../filed-returns-summary-sheet.test.ts | 25 +++++++++++++++++++ tests/entrypoints/offscreen.test.ts | 10 ++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/connectors/filed-returns-summary-sheet.test.ts b/tests/connectors/filed-returns-summary-sheet.test.ts index b9756f5f..8740a4ce 100644 --- a/tests/connectors/filed-returns-summary-sheet.test.ts +++ b/tests/connectors/filed-returns-summary-sheet.test.ts @@ -27,6 +27,31 @@ const syntheticJwt = [ ].join("."); describe("filed-return full-year summary sheet", () => { + it("emits the single planned financial year in every CSV row", () => { + const summary = buildFiledReturnsSummarySheet( + [jsonPlan("April", "april-data.json", "GSTR-3B")], + [jsonEntry("april-data.json", "GSTR-3B", { sup_details: { osup_det: { txval: 12.5 } } })], + ); + + expect(new TextDecoder().decode(summary.dataBytes)).toBe( + "financial_year,period,return_type,artifact,outcome,field_label,field_path,value_text,value_number\n" + + "2026-27,April,GSTR-3B,JSON,parseable-json,,/ret_period,042026,\n" + + '2026-27,April,GSTR-3B,JSON,parseable-json,"Table 3.1(a) Outward taxable supplies (other than zero rated, nil rated and exempted) — Taxable value",/sup_details/osup_det/txval,,12.5\n', + ); + }); + + it("refuses a mixed-year plan before it can form a CSV", () => { + expect(() => + buildFiledReturnsSummarySheet( + [ + jsonPlan("April", "april-data.json", "GSTR-3B"), + { ...jsonPlan("May", "may-data.json", "GSTR-3B"), financialYear: "2027-28" }, + ], + [], + ), + ).toThrow("Filed-return summary plan must have one financial year."); + }); + it("refuses an identity-shaped array discriminator instead of embedding it in a path", () => { const build = (ty: string) => buildFiledReturnsSummarySheet( diff --git a/tests/entrypoints/offscreen.test.ts b/tests/entrypoints/offscreen.test.ts index 764ce155..8b746844 100644 --- a/tests/entrypoints/offscreen.test.ts +++ b/tests/entrypoints/offscreen.test.ts @@ -597,10 +597,10 @@ describe("offscreen Blob URL entrypoint", () => { ]); const summary = new TextDecoder().decode(entries.get("full-year-summary.csv")); expect(summary.split("\n")[0]).toBe( - "period,return_type,artifact,outcome,field_label,field_path,value_text,value_number", + "financial_year,period,return_type,artifact,outcome,field_label,field_path,value_text,value_number", ); - expect(summary).toContain("April,GSTR-3B,JSON,parseable-json,,/portal_leaf,,11"); - expect(summary).toContain("May,GSTR-3B,JSON,parseable-json,,/other_portal_leaf,,22"); + expect(summary).toContain("2026-27,April,GSTR-3B,JSON,parseable-json,,/portal_leaf,,11"); + expect(summary).toContain("2026-27,May,GSTR-3B,JSON,parseable-json,,/other_portal_leaf,,22"); expect(summary).not.toContain("900"); expect(summary).not.toContain("800"); expect(summary).not.toContain("700"); @@ -712,7 +712,7 @@ describe("offscreen Blob URL entrypoint", () => { }); const entries = await extractStoredZipEntries(createdBlobs[0]!); expect(new TextDecoder().decode(entries.get("full-year-summary.csv"))).toContain( - "April,GSTR-3B,PDF,non-json-artifact", + "2026-27,April,GSTR-3B,PDF,non-json-artifact", ); }); @@ -760,7 +760,7 @@ describe("offscreen Blob URL entrypoint", () => { const entries = await extractStoredZipEntries(createdBlobs[0]!); expect([...entries.keys()]).toEqual(["april-summary.pdf", "full-year-summary.csv"]); expect(new TextDecoder().decode(entries.get("full-year-summary.csv"))).toContain( - "April,GSTR-1,PDF,non-json-artifact", + "2026-27,April,GSTR-1,PDF,non-json-artifact", ); }); From 682c68f46381b8eff9c55951c248cece8235feb5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 24 Aug 2026 15:16:29 +0530 Subject: [PATCH 3/3] docs(summary): describe CSV financial year --- README.md | 10 ++++++---- docs/PRIVACY_QA.md | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 25647736..8095ba1b 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,10 @@ value this build cannot render without changing it, the run keeps the tidy CSV and reports that the workbook is unavailable. A GSTR-1 run adds only the tidy CSV. No run emits a blank or mislabelled GSTR-3B workbook, and the former standalone context CSV is not emitted. The data CSV has the -fixed columns `period`, `return_type`, `artifact`, `outcome`, `field_label`, -`field_path`, `value_text`, and `value_number`, with one row per period and -flattened field. Periods and artifacts without parseable JSON receive fixed +fixed columns `financial_year`, `period`, `return_type`, `artifact`, `outcome`, +`field_label`, `field_path`, `value_text`, and `value_number`; the selected +financial year is repeated in every row so an extracted CSV identifies its +scope. Periods and artifacts without parseable JSON receive fixed outcome rows instead of fabricated zeroes. The exact shaping rules are recorded below for the producing Pack version. @@ -160,7 +161,8 @@ are not assigned to any released Pack version because no release contains this format yet. The producing Pack version is available in the installed extension manifest. Neither file carries an in-file format marker, so a machine consumer cannot identify the CSV format from the CSV alone and must be given the -producing Pack version. +producing Pack version. Its `financial_year` column nevertheless identifies the +selected year in every row. - **Envelope rule:** Pack classifies identity against the whole JSON document, then removes the artifact validator's documented return envelope before diff --git a/docs/PRIVACY_QA.md b/docs/PRIVACY_QA.md index ca6c2940..3fd5aac4 100644 --- a/docs/PRIVACY_QA.md +++ b/docs/PRIVACY_QA.md @@ -83,8 +83,9 @@ For each release candidate: - No assembly may emit a GSTR-3B workbook for another return type. The standalone context CSV must be absent. The data CSV must keep - the fixed tidy columns `period`, `return_type`, `artifact`, `outcome`, - `field_label`, `field_path`, `value_text`, and `value_number`; keep canonical + the fixed tidy columns `financial_year`, `period`, `return_type`, `artifact`, + `outcome`, `field_label`, `field_path`, `value_text`, and `value_number`; + repeat the selected financial year in every row and keep canonical JSON Pointer paths. Confirm only the configured GSTR-3B summary arrays with at most 64 elements may expand, using the first shared discriminator in the ordered candidate list `ty`, `pos`, and only when every discriminator is @@ -146,7 +147,8 @@ For each release candidate: the README, not in generated rule rows or a second sheet. The workbook and CSV have no in-file format marker; a machine consumer of a separated CSV must be given the producing Pack version because it cannot infer that version from - the CSV alone. + the CSV alone; the `financial_year` column still identifies the selected year + in every row. Both derived files must remain output-only ZIP entries: their bytes may be transient in extension-controlled memory before browser handoff and