From e6082497d39e1ec7052d3b9c9887afdf001cd161 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:23:30 +0530 Subject: [PATCH 01/12] fix(gst): bind a GSTR-2B refusal to the period on screen The summary route does not change per period and keeps rendering whichever period it last loaded, so a refusal read without its header answered for every later period in a fiscal-year run. One live run reported twelve periods processed after a single navigation, while the page still read April. For a taxpayer with a statement in some months and not others, that silently marks drafted months as absent and skips their downloads. The refusal is now recorded only when the visible header confirms the requested period, and the guard fails closed: an unreadable header is could not determine, never matches. A page showing some other period is left rather than answered for. Both the refusal branch and the download-ready branch asked that same question in the same six lines, so it has a name now. Registering the page-identity signals is the other half. They were transient while the only step carrying them was the ready hand-off, whose signals never reach durable state; recording the refusal made them terminal. One unregistered token rejects the whole array, so every period the portal declined was stored as needing review instead. --- src/connectors/gst/gstr2b-flow.ts | 84 +++++++++++++++++++++++++--- src/connectors/gst/gstr2b-summary.ts | 6 ++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/connectors/gst/gstr2b-flow.ts b/src/connectors/gst/gstr2b-flow.ts index 21b96a47..ec642a12 100644 --- a/src/connectors/gst/gstr2b-flow.ts +++ b/src/connectors/gst/gstr2b-flow.ts @@ -21,6 +21,7 @@ import { hasGstr2bLoginEvidence, isGstr2bAuthRoute, isGstr2bSummaryPage, + isGstr2bSummaryRoute, readDocumentText, returnFromMismatchedGstr2bSummary, verifyVisibleGstr2bPeriod, @@ -32,6 +33,34 @@ import { isReturnDashboardStillRendering, selectGstr2bReturnDashboardFiltersAndSearch, } from "./gstr2b-dashboard-filters"; +import { + GSTR2B_NOT_GENERATED_SAFE_MESSAGE, + isGstr2bNotGeneratedText, +} from "./filed-returns-post-click-blocked-state"; + +/** + * `null` when the visible page is the requested period, otherwise the step that leaves it. + * + * The summary route renders whichever period it last loaded, so both callers must confirm the + * header before trusting anything on the page -- one to record a refusal, the other to click a + * download. Failing closed is the point: an unreadable header is "could not determine". + */ +function leaveUnlessVisiblePeriodMatches( + documentRef: Document, + normalisedText: string, + scope: FiledReturnsDownloadScope, + scopeId: string, + safeSignals: readonly string[], +): PortalFlowStepResult | null { + const periodGuard = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope); + if (!periodGuard) return null; + return ( + returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ + ...safeSignals, + ...periodGuard.safeSignals, + ]) ?? periodGuard + ); +} const FILED_RETURNS_ROUTE = /\/returns\/auth\/efiledReturns\/?$/i; @@ -73,6 +102,44 @@ export async function runGstr2bDownloadStep( }; } + // Recognised here, during observation, rather than after a click. The portal renders this panel + // instead of the download control, so the flow would otherwise wait out its whole step budget + // for a control that is never coming, then stop the fiscal-year run on a period that cannot + // produce an artifact. A blocked step ends the wait and lets the period be recorded as absent. + // + // Bound to the visible period, because this panel keeps rendering the period it was last loaded + // for. Unbound, one period's refusal answered for every later period in a fiscal-year run, which + // recorded eleven months the run never navigated to. The guard fails closed: an unreadable + // period is "could not determine", never "matches". + if (isGstr2bSummaryRoute(documentRef) && isGstr2bNotGeneratedText(normalised)) { + const leaving = leaveUnlessVisiblePeriodMatches( + documentRef, + normalised, + scope, + scopeId, + safeSignals, + ); + if (leaving) return leaving; + return { + connectorId: "gst", + scopeId, + state: "blocked", + safeSignals: [ + ...safeSignals, + "gstr2b-summary-route", + "gstr2b-visible-period-verified", + "filed-gstr2b-not-generated", + ], + safeMessage: GSTR2B_NOT_GENERATED_SAFE_MESSAGE, + userAction: { + type: "RETRY_PORTAL_GENERATION", + message: + "Check the GST Portal's stated reason for this period. Retry only once the portal generates a GSTR-2B for it.", + canResume: true, + }, + }; + } + const mismatchedReturnNavigation = returnFromMismatchedReturnPage( documentRef, scope, @@ -81,15 +148,14 @@ export async function runGstr2bDownloadStep( if (mismatchedReturnNavigation) return mismatchedReturnNavigation; if (isGstr2bSummaryPage(documentRef, normalised)) { - const periodGuard = verifyVisibleGstr2bPeriod(documentRef, normalised, scope); - if (periodGuard) { - const recovery = returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ - ...safeSignals, - ...periodGuard.safeSignals, - ]); - if (recovery) return recovery; - return periodGuard; - } + const leaving = leaveUnlessVisiblePeriodMatches( + documentRef, + normalised, + scope, + scopeId, + safeSignals, + ); + if (leaving) return leaving; return { connectorId: "gst", scopeId, diff --git a/src/connectors/gst/gstr2b-summary.ts b/src/connectors/gst/gstr2b-summary.ts index 8f4fb9e7..9bc6674e 100644 --- a/src/connectors/gst/gstr2b-summary.ts +++ b/src/connectors/gst/gstr2b-summary.ts @@ -13,6 +13,12 @@ import { filedReturnScopeId } from "./filed-returns-return-descriptors"; const GSTR2B_SUMMARY_ROUTE = /\/gstr2b\/auth\/gstr2b\/summary\/?$/i; const GSTR2B_AUTH_ROUTE = /\/gstr2b\/auth(?:\/|$)/i; +// The summary route alone. `isGstr2bSummaryPage` additionally requires the download controls, so +// it cannot identify the variant of this page where the portal renders a refusal in their place. +export function isGstr2bSummaryRoute(documentRef: Document): boolean { + return GSTR2B_SUMMARY_ROUTE.test(documentRef.defaultView?.location.pathname ?? ""); +} + export function isGstr2bSummaryPage(documentRef: Document, normalisedText: string): boolean { const pathname = documentRef.defaultView?.location.pathname ?? ""; return ( From d5920fdaa6712572efbf20ebf27ff245b8e515a5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:23:31 +0530 Subject: [PATCH 02/12] test(gst): pin that a terminal step's signals survive durable storage The transient ready step carried an unregistered token harmlessly for months, so the guard asserts the property that actually matters: a step whose signals get persisted must produce signals the durable parser accepts. --- ...-returns-flow-gstr2b-not-generated.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts diff --git a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts new file mode 100644 index 00000000..9d61a764 --- /dev/null +++ b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { runFiledReturnsDownloadStep } from "../../src/connectors/gst/filed-returns-flow"; +import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; +import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; + +// Captured live. The portal replaces the download controls with this panel, and it keeps rendering +// the period it was last loaded for until a new search settles -- which is what made a stale page +// answer for eleven periods the run never visited. +function createGstr2bNotGeneratedDocument(period: string, financialYear = "2025-26"): Document { + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+

Financial Year - ${financialYear}

+

Return Period - ${period}

+

Generation date -

+
+ Error! + GSTR-2B could not be generated by the System. Kindly compute your GSTR 2B manually by + clicking Compute GSTR-2B button available at IMS Dashboard. +
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + return documentRef; +} + +describe("GSTR-2B the portal declined to generate", () => { + // The portal renders this panel where the download control would be, so waiting for that control + // burns the whole step budget and then stops the fiscal-year run on a period that can never + // produce an artifact. Recognising the refusal during observation ends the wait, and a blocked + // step is not continue-able, so the period is recorded absent and the run advances. + it("records the period whose refusal is actually on screen", async () => { + const documentRef = createGstr2bNotGeneratedDocument("April"); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).toContain("filed-gstr2b-not-generated"); + expect(result.state).toBe("blocked"); + // `shouldContinueFlow` only continues on a clicked step, so a blocked one stops the wait. + expect(result.state).not.toBe("clicked"); + }); + + it("refuses to answer for a period the visible refusal is not about", async () => { + // The run has moved on to May; the portal still shows April's panel because no new search has + // settled. Reading this as May's answer marks a period the run never navigated to. + const documentRef = createGstr2bNotGeneratedDocument("April"); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + }); + + it("refuses to answer when the visible period cannot be read at all", async () => { + // Fail closed: an unlabelled panel is "could not determine", never "matches". + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+
GSTR-2B could not be generated by the System.
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + }); +}); + +describe("recovering from a stale refusal", () => { + it("goes back to the dashboard to select the period it was asked for", async () => { + const documentRef = createGstr2bNotGeneratedDocument("April"); + const back = documentRef.createElement("button"); + back.textContent = "BACK TO DASHBOARD"; + documentRef.querySelector("main")?.append(back); + let clicked = 0; + back.addEventListener("click", () => { + clicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + // Observable outcome: Pack navigates, rather than answering for a page it never loaded or + // stalling until the step budget runs out. + expect(clicked).toBe(1); + expect(result.state).toBe("clicked"); + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + }); +}); + +describe("the signals a terminal step leaves behind", () => { + // A terminal step's signals are persisted. One token the allowlist has never been told about + // rejects the entire array, which blocks the target -- so a live run recorded "needs review" for + // all seven periods the portal had plainly refused. The transient "ready" step carried the same + // token harmlessly for months, because its signals never reach durable state. + it.each([ + ["the refusal it records", "April"], + ["the stale page it walks away from", "May"], + ])("survives storage: %s", async (_label, period) => { + const documentRef = createGstr2bNotGeneratedDocument("April"); + + const step = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period, + returnType: "GSTR-2B", + }); + + expect(parseDurableFiledReturnsSignals(step.safeSignals)).not.toBeNull(); + }); +}); From 1bad5d00e7b4802142c3883beca2ff8f6822ca92 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:23:31 +0530 Subject: [PATCH 03/12] fix(panel): name which run a pause belongs to Both pause reasons can render at once, and unlabelled they read as one contradictory statement: a live run showed could not start a full fiscal year run, the all-year plan's reason, directly above a single-return run that had started and processed twelve periods. --- src/entrypoints/panel/panel-surface.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/entrypoints/panel/panel-surface.tsx b/src/entrypoints/panel/panel-surface.tsx index 308284bc..f967e98c 100644 --- a/src/entrypoints/panel/panel-surface.tsx +++ b/src/entrypoints/panel/panel-surface.tsx @@ -241,8 +241,12 @@ export function PanelSurface({ pack }: { pack: PackPanelController }) { )} {allSupportedNeedsRecovery ? ( + // Named for its own run. Both blocks can render at once, and unlabelled they read as + // one contradictory statement: a live run showed "could not start a full fiscal year + // run" -- the all-year plan's reason -- directly above a single-return run that had + // plainly started and processed twelve periods.

- Why Pack paused: {allSupportedSummary?.flowStep.safeMessage} + Why the all-returns year plan paused: {allSupportedSummary?.flowStep.safeMessage}

) : null} {hasRecoveryActions(summary ?? null) ? ( From ec75c0e2291ad1b30b6cc6b130224fc82f6077b2 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:23:31 +0530 Subject: [PATCH 04/12] docs(gst): record that the refusal panel outlives the period it was loaded for --- docs/PORTAL_INTEGRATION_FINDINGS.md | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/docs/PORTAL_INTEGRATION_FINDINGS.md b/docs/PORTAL_INTEGRATION_FINDINGS.md index 6a32c995..9b38446f 100644 --- a/docs/PORTAL_INTEGRATION_FINDINGS.md +++ b/docs/PORTAL_INTEGRATION_FINDINGS.md @@ -412,3 +412,95 @@ read-only live probe confirms a stable, scope-bound source for the necessary fac cadences and registration states. Until then, the evidence supports neither replacing the calendar threshold nor suppressing a reader-selected period; record a bounded, user-visible not-filed or unresolved outcome when the acquisition flow establishes one. + +## Filed GSTR-1 detail route can be the download surface itself (2026-09-10) + +Captured live, authenticated, source-surfaces build v0.6.0, on the GSTR-1 detail route +(`/returns/auth/gstr1`) for a monthly period whose header read `Status - Filed`. + +**The page carried four controls and no navigation step:** + +``` +BACK DOWNLOAD DETAILS FROM E-INVOICES (EXCEL) RESET (disabled) DOWNLOAD FILED (PDF) +``` + +There was no `VIEW SUMMARY` control anywhere on the page — confirmed against the full page, header +band through footer. The page body was the `File Nil GSTR-1` form with its four-condition note, +which is what the portal renders for this period even though the return is filed. + +**What this falsifies.** Pack assumed a filed GSTR-1 PDF is always reached by clicking +`View Summary` to move from the detail route to the summary route, and that the PDF control is +labelled `DOWNLOAD FILED GSTR-1`. Neither held here. `clickFiledGstr1SummaryForPdf` searched for a +control that does not exist on this page and re-emitted `filed-gstr1-summary-view-pending` on every +step until the flow step limit, so the run reported `blocked` / `user-action-required` with no +action a user could take. Observed signals: `gstr-1-detail-route`, +`filed-gstr1-summary-view-pending`, `flow-step-limit-reached`. + +**Why both layers missed the control.** The label is `DOWNLOAD FILED (PDF)` — no return type after +`FILED`. `explicitDownloadPattern` for GSTR-1 required `download filed gstr1`, and +`secondaryDownloadPattern` required `download` immediately before `pdf`, so the intervening `filed` +defeated it. The observer carried its own inline copy of that second pattern rather than reading +the descriptor, so the same fact had to be wrong in two places at once. + +**Not yet established.** Whether this shape is specific to periods filed as NIL, or is how the +portal now renders every filed monthly GSTR-1. One period was captured. The fix keys off the +presence of the download control rather than off the NIL form, so it does not depend on that +answer, but the answer is still unrecorded. + +## The portal declines to produce an artifact, in its own words (2026-09-10) + +Two captures, live and authenticated, source-surfaces build. Neither is a failure to reach the +portal; both are the portal stating that the artifact does not exist for that period. One concerns +a filed return, GSTR-1; the other concerns the auto-drafted GSTR-2B statement, which the portal +drafts rather than the taxpayer filing it. + +**Filed GSTR-1, e-invoice details workbook.** Clicking the Excel control raises a modal: + +``` +Information +No details available for download (This is relevant only if you have reported e-invoices). + [ OK ] +``` + +The taxpayer reports no e-invoices, so there is no workbook. The page's own advisory says as much: +the file "would be blank in case taxpayer is not e-invoicing". + +**GSTR-2B summary.** The summary route renders an error panel: + +``` +Error! +GSTR-2B could not be generated by the System. ... +Attention: System will not generate GSTR 2B for the current return period in any one of the +following circumstances: + i. There are no records to generate GSTR 2B for the current return period + ii. GSTR 3B of last return period is not filed till GSTR 2B generation date of current return period + iii. You are a QRMP taxpayer and current return period is not a quarter ending month +``` + +**Why both mattered.** Retrying cannot change either within a run, yet Pack offered retry as the +remedy and stopped the fiscal-year run. The GSTR-1 case had a recogniser and a ledger path for +recording the absence; nothing sent the message between them, so it never ran. The GSTR-2B case had +no recogniser at all. + +**Matched on the statement, not the causes.** The GSTR-2B panel's numbered conditions are advisory +text and can be reworded independently of the outcome; the outcome sentence is what Pack keys on. + +**Not yet established.** Whether the GSTR-1 modal text varies for a taxpayer who does report +e-invoices but has none in a period, and whether GSTR-2B uses this same panel for a QRMP taxpayer +mid-quarter or a different one. One capture of each. + +### The refusal panel outlives the period it was loaded for (2026-09-10) + +Captured live during a full-fiscal-year run. The GSTR-2B summary route does not change per period +(`/gstr2b/auth/gstr2b/summary`), and after the portal renders the "could not be generated" panel it +keeps rendering that panel -- and the header block naming the period it belongs to -- until a new +dashboard search settles. A run that read the panel without also reading the header therefore got a +confident answer for every later period without navigating to any of them: one taxpayer's run +reported twelve periods processed after a single navigation, while the page still read +`Return Period - April`. + +The header is what makes the correct reading possible. Even with the download controls replaced by +the error panel, the portal still renders `GSTIN`, `Financial Year`, `Return Period` and +`Generation date` (the last one empty, which is itself the signal that nothing was drafted). So the +refusal is bindable to a period, and must be bound to one before it is recorded. An unreadable +header is "could not determine", not "matches" -- Pack navigates instead of answering. From 0f781525d9a338a2b90e1ffba25e4341ec47896e Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:59:22 +0530 Subject: [PATCH 05/12] fix(gst): report a declined period the same way in an all-returns year The single-return fiscal-year path maps a target status to an outcome through an exhaustive record, so a status it has not been told about fails to compile. The all-returns path kept two hand-written copies of that mapping, each ending in a needs-review default that silently absorbed one. A period the portal declined to generate therefore read as resolved in one run type and as needing a person in the other, and a run of everything stopped on periods that could never change while the same year run for one return completed and exported its ZIP. Both copies now call the shared record. --- ...-all-supported-full-fiscal-year-summary.ts | 24 +++++++++---------- ...-returns-all-supported-full-fiscal-year.ts | 24 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts index b65c41b3..4165624d 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts @@ -1,3 +1,4 @@ +import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearTargetEvidence, @@ -275,16 +276,15 @@ function targetOutcome( target: FiledReturnsAllSupportedFullFiscalYearTarget, zipDelivered: boolean, ): FiledReturnsAllSupportedFullFiscalYearTargetEvidence["outcome"] { - if (target.status === "not-filed") return "not-filed"; - if (target.status === "downloaded") { - if (!zipDelivered) return "captured"; - return target.safeSignals.some((signal) => - signal.startsWith("filed-return-artifact-unavailable:"), - ) - ? "partly-saved" - : "saved"; - } - if (target.status === "pending") return "pending"; - if (target.status === "running") return "running"; - return "needs-review"; + // The same exhaustive mapping the single-return fiscal-year path uses. Two hand-written copies + // stood here, each ending in a `needs-review` default that silently absorbed any status they had + // not been told about -- so a period the portal declined to generate was reported to the user as + // needing review, in the one run type where it could not be. The shared record fails to compile + // instead, which is the only reason the single-return path was already right. + return filedReturnsTargetOutcome( + target.status, + zipDelivered, + false, + target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")), + ); } diff --git a/src/background/filed-returns-all-supported-full-fiscal-year.ts b/src/background/filed-returns-all-supported-full-fiscal-year.ts index 3813a534..de4f54b1 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year.ts @@ -1,3 +1,4 @@ +import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearRequest, @@ -745,18 +746,17 @@ function targetOutcome( target: FiledReturnsAllSupportedFullFiscalYearTarget, zipDelivered: boolean, ): FiledReturnsAllSupportedFullFiscalYearFlowSummary["targetEvidence"][number]["outcome"] { - if (target.status === "not-filed") return "not-filed"; - if (target.status === "downloaded") { - if (!zipDelivered) return "captured"; - return target.safeSignals.some((signal) => - signal.startsWith("filed-return-artifact-unavailable:"), - ) - ? "partly-saved" - : "saved"; - } - if (target.status === "pending") return "pending"; - if (target.status === "running") return "running"; - return "needs-review"; + // The same exhaustive mapping the single-return fiscal-year path uses. Two hand-written copies + // stood here, each ending in a `needs-review` default that silently absorbed any status they had + // not been told about -- so a period the portal declined to generate was reported to the user as + // needing review, in the one run type where it could not be. The shared record fails to compile + // instead, which is the only reason the single-return path was already right. + return filedReturnsTargetOutcome( + target.status, + zipDelivered, + false, + target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")), + ); } function scopeForTarget( From 0e6fe9e31d0bffb67066efd68e7033172f080a53 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:59:22 +0530 Subject: [PATCH 06/12] test(gst): pin that both run types report a declined period alike --- ...-supported-full-fiscal-year-ledger.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/background/all-supported-full-fiscal-year-ledger.test.ts b/tests/background/all-supported-full-fiscal-year-ledger.test.ts index 90f31f2b..32300dd5 100644 --- a/tests/background/all-supported-full-fiscal-year-ledger.test.ts +++ b/tests/background/all-supported-full-fiscal-year-ledger.test.ts @@ -6,6 +6,8 @@ import { canonicalDurableTargetStatus } from "../../src/connectors/gst/filed-ret import { allSupportedExplicitRetryTarget, createAllSupportedFullFiscalYearLedger, + markAllSupportedFullFiscalYearTargetRunning, + markAllSupportedFullFiscalYearTargetTerminal, createAllSupportedFullFiscalYearTargetPlan, } from "../../src/background/filed-returns-all-supported-full-fiscal-year-ledger"; import { @@ -610,3 +612,37 @@ describe("all-supported full-fiscal-year ledger", () => { ); }); }); + +describe("a period the portal declined to generate, in an all-returns year", () => { + // The single-return fiscal-year path reported this correctly from the day the status existed, + // because its status-to-outcome mapping is an exhaustive record that fails to compile when a + // status is missing. The all-returns path kept two hand-written copies ending in a + // `needs-review` default, so the same period read as needing a person in one run type and as + // resolved in the other -- and a run of everything stopped on periods that could never change. + it("reports it as not generated, not as needing review", () => { + let ledger = createLedger(); + const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); + if (!target) throw new Error("expected a GSTR-2B target in the all-returns plan"); + + ledger = markAllSupportedFullFiscalYearTargetRunning(ledger, target.targetId, NOW); + ledger = markAllSupportedFullFiscalYearTargetTerminal( + ledger, + target.targetId, + "not-generated", + { + connectorId: "gst", + scopeId: "gst-gstr2b-private-v0", + state: "blocked", + safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeMessage: "x", + } as never, + NOW, + ); + + const summary = toAllSupportedFullFiscalYearSummary(ledger); + const evidence = summary.targetEvidence.find((row) => row.targetId === target.targetId); + + expect(evidence?.outcome).toBe("not-generated"); + expect(evidence?.outcome).not.toBe("needs-review"); + }); +}); From 708f12e4febc5ecbfa9647c7bb80e13a2c87c076 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:21:54 +0530 Subject: [PATCH 07/12] docs(gst): record what the portal's surfaces do, not what a return says Two review findings, both correct on the values and one over-broad on the route. A filing status read from a live authenticated page, and a statement that the taxpayer reports no e-invoices, are both facts about a return rather than about the portal. Neither is needed: the findings are that the detail route can carry the control, and that the portal returns an unavailable-workbook modal when a period has no e-invoice records. Both now state the portal's rule, with its own advisory quoted as the evidence for it. A third of the same class was not flagged and is gone too -- a period name quoted from the stale header. The finding is that the panel keeps rendering whichever period it last loaded, which does not need the period named. The route itself stays. It is already published in this file on master and exported from src/connectors/gst/portal-artifact-endpoints.ts as GSTR1_DETAIL_PATH, so it discloses nothing this repository does not already state, and the finding cannot be recorded without naming the surface it is about. --- docs/PORTAL_INTEGRATION_FINDINGS.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/PORTAL_INTEGRATION_FINDINGS.md b/docs/PORTAL_INTEGRATION_FINDINGS.md index 9b38446f..dd598278 100644 --- a/docs/PORTAL_INTEGRATION_FINDINGS.md +++ b/docs/PORTAL_INTEGRATION_FINDINGS.md @@ -416,7 +416,8 @@ unresolved outcome when the acquisition flow establishes one. ## Filed GSTR-1 detail route can be the download surface itself (2026-09-10) Captured live, authenticated, source-surfaces build v0.6.0, on the GSTR-1 detail route -(`/returns/auth/gstr1`) for a monthly period whose header read `Status - Filed`. +(`/returns/auth/gstr1`) for a filed monthly period. No value read from the page is reproduced +here: this file records what the portal's surfaces do, not what any return says. **The page carried four controls and no navigation step:** @@ -462,8 +463,11 @@ No details available for download (This is relevant only if you have reported e- [ OK ] ``` -The taxpayer reports no e-invoices, so there is no workbook. The page's own advisory says as much: -the file "would be blank in case taxpayer is not e-invoicing". +The portal returns this when the period has no e-invoice records to export, which its own advisory +states: the file "would be blank in case taxpayer is not e-invoicing". That is the portal's rule, +quoted from its own copy. Whether it applies to a given return is a fact about that return, and not +one this file records. Pack treats the modal as the artifact being unavailable and records it as +such. **GSTR-2B summary.** The summary route renders an error panel: @@ -495,9 +499,9 @@ Captured live during a full-fiscal-year run. The GSTR-2B summary route does not (`/gstr2b/auth/gstr2b/summary`), and after the portal renders the "could not be generated" panel it keeps rendering that panel -- and the header block naming the period it belongs to -- until a new dashboard search settles. A run that read the panel without also reading the header therefore got a -confident answer for every later period without navigating to any of them: one taxpayer's run -reported twelve periods processed after a single navigation, while the page still read -`Return Period - April`. +confident answer for every later period without navigating to any of them: a full-year run reported +twelve periods processed after a single navigation, while the header still named the first period +of that run. The header is what makes the correct reading possible. Even with the download controls replaced by the error panel, the portal still renders `GSTIN`, `Financial Year`, `Return Period` and From a5baeb271f1cc4017ef121d78ca21921db25420e Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:16:35 +0530 Subject: [PATCH 08/12] fix(gst): require visible evidence before recording a refusal `verifyVisibleGstr2bPeriod` accepted the page's own inline configuration in place of the header a reader can see. Two callers reach it and they are not alike: a download click may lean on that configuration, because the file it produces is correlated to the target before the target counts as complete; a refusal may not, because it resolves the target outright and no artifact follows to corroborate it. So an unlabelled refusal panel whose inline config happened to match resolved a period as not-generated and advanced a fiscal-year run, emitting `gstr2b-visible-period-verified` for an identity nothing visible confirmed. The refusal path now asks for visible evidence and the signal tells the truth. The branch ordering also collapses: complete labels or the statement heading pass, inline config passes only where it is allowed to. --- src/connectors/gst/gstr2b-flow.ts | 12 +++++++++++- src/connectors/gst/gstr2b-summary.ts | 22 ++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/connectors/gst/gstr2b-flow.ts b/src/connectors/gst/gstr2b-flow.ts index ec642a12..960f3be0 100644 --- a/src/connectors/gst/gstr2b-flow.ts +++ b/src/connectors/gst/gstr2b-flow.ts @@ -44,6 +44,9 @@ import { * The summary route renders whichever period it last loaded, so both callers must confirm the * header before trusting anything on the page -- one to record a refusal, the other to click a * download. Failing closed is the point: an unreadable header is "could not determine". + * + * `requireVisibleEvidence` is the difference between them, and it is the refusal that sets it: + * see `verifyVisibleGstr2bPeriod`. */ function leaveUnlessVisiblePeriodMatches( documentRef: Document, @@ -51,8 +54,14 @@ function leaveUnlessVisiblePeriodMatches( scope: FiledReturnsDownloadScope, scopeId: string, safeSignals: readonly string[], + requireVisibleEvidence = false, ): PortalFlowStepResult | null { - const periodGuard = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope); + const periodGuard = verifyVisibleGstr2bPeriod( + documentRef, + normalisedText, + scope, + requireVisibleEvidence, + ); if (!periodGuard) return null; return ( returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ @@ -118,6 +127,7 @@ export async function runGstr2bDownloadStep( scope, scopeId, safeSignals, + true, ); if (leaving) return leaving; return { diff --git a/src/connectors/gst/gstr2b-summary.ts b/src/connectors/gst/gstr2b-summary.ts index 9bc6674e..035aedfd 100644 --- a/src/connectors/gst/gstr2b-summary.ts +++ b/src/connectors/gst/gstr2b-summary.ts @@ -59,10 +59,19 @@ export function verifyVisibleGstr2bSummaryScope( return verifyVisibleGstr2bPeriod(documentRef, normalised, scope); } +/** + * `null` when this page is the requested period, otherwise the mismatch that rejects it. + * + * `requireVisibleEvidence` decides whether the page's own inline configuration may stand in for + * the identity a reader can see. A download click may rely on it, because the file that follows + * is correlated to this target before the target counts as complete. A refusal may not: it + * resolves the target outright, so the visible header is the only evidence there will ever be. + */ export function verifyVisibleGstr2bPeriod( documentRef: Document, normalisedText: string, scope: FiledReturnsDownloadScope, + requireVisibleEvidence = false, ): PortalDownloadTriggerResult | null { const serverScope = extractGstr2bServerScope(documentRef); const visiblePeriod = extractGstr2bLabelValue(normalisedText, "return period"); @@ -86,16 +95,13 @@ export function verifyVisibleGstr2bPeriod( return gstr2bPeriodMismatch(serverScope ? ["gstr2b-server-visible-period-conflict"] : []); } - if (hasCompleteLabelledEvidence) return null; + // Whole-page month/year matches are not target evidence: generated-on text and table content + // can mention another period. Only labels or the portal statement heading qualify as visible. + if (hasCompleteLabelledEvidence || statementScope) return null; - if (serverScope) return null; + if (serverScope && !requireVisibleEvidence) return null; - if (!statementScope) { - // Whole-page month/year matches are not target evidence: generated-on text and table - // content can mention another period. Only labels or the portal statement heading qualify. - return gstr2bPeriodMismatch(["gstr2b-labelled-period-evidence-missing"]); - } - return null; + return gstr2bPeriodMismatch(["gstr2b-labelled-period-evidence-missing"]); function gstr2bPeriodMismatch(extraSignals: string[]): PortalDownloadTriggerResult { return { From a2316bdf9e49c7578dcc9a03b8f23237fbe60ecc Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:16:36 +0530 Subject: [PATCH 09/12] test(gst): pin that a refusal needs more than the page's own config Fails without the guard: the unlabelled panel records the period instead of leaving it. --- ...-returns-flow-gstr2b-not-generated.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts index 9d61a764..f8087726 100644 --- a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts +++ b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts @@ -85,6 +85,39 @@ describe("GSTR-2B the portal declined to generate", () => { expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); }); + + it("refuses to answer on the page's own configuration when nothing visible says so", async () => { + // The inline config is what the page asked the server for, not what a reader can see, and the + // refusal resolves the period outright -- no artifact follows to corroborate it. A download + // click may lean on this config because its file is correlated afterwards; a refusal may not. + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+
GSTR-2B could not be generated by the System.
+
+ + `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + expect(result.safeSignals).toContain("gstr2b-labelled-period-evidence-missing"); + }); }); describe("recovering from a stale refusal", () => { From a573afaedf2d1ecae3bf8886417d0da3b48d3355 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:58:29 +0530 Subject: [PATCH 10/12] fix(gst): hold the post-click refusal to the same evidence bar The post-click path inherits the binding from its base; this holds it to the stricter reading introduced here. A refusal resolves its target outright and no artifact follows to corroborate it, so the page's own inline configuration cannot stand in for a header a reader can see -- on either path. Both refusal paths now pass `requireVisibleEvidence`. The download path still does not, because its file is correlated to the target before the target counts. --- src/connectors/gst/filed-returns-post-click-blocked-state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connectors/gst/filed-returns-post-click-blocked-state.ts b/src/connectors/gst/filed-returns-post-click-blocked-state.ts index 49524e55..70adef3b 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -102,7 +102,7 @@ function detectGstr2bNotGenerated( // // A target is a scope with an action id, so the same guard the observation path uses applies // unchanged here. It fails closed: an unreadable header is "could not determine". - if (verifyVisibleGstr2bPeriod(documentRef, normalised, target)) return null; + if (verifyVisibleGstr2bPeriod(documentRef, normalised, target, true)) return null; return { connectorId: "gst", From ddab9f28fed6e019f5adc8382aa9911e1f6b3d75 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:58:29 +0530 Subject: [PATCH 11/12] test(gst): pin the config-only refusal on the post-click path Fails when the flag is dropped at that call site. --- ...d-returns-post-click-blocked-state.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/connectors/filed-returns-post-click-blocked-state.test.ts b/tests/connectors/filed-returns-post-click-blocked-state.test.ts index 7846ee3d..22a0cad9 100644 --- a/tests/connectors/filed-returns-post-click-blocked-state.test.ts +++ b/tests/connectors/filed-returns-post-click-blocked-state.test.ts @@ -98,6 +98,28 @@ describe("GSTR-2B the portal did not generate", () => { expect(result).toBeNull(); }); + it("refuses to answer on the page's own configuration when nothing visible says so", () => { + // Same rule as the observation path: a refusal resolves the target outright and no artifact + // follows to corroborate it, so the inline config cannot stand in for the visible header. + const documentRef = createGstDocument( + ` +
+
GSTR-2B could not be generated by the System.
+
+ + `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])).toBeNull(); + }); + it("refuses to answer when nothing visible names the period at all", () => { // Fail closed: an unlabelled panel is "could not determine", never "matches". const documentRef = createGstDocument( From 69380d7c316c21621364e2d5d73b870fd1fc197a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:59:58 +0530 Subject: [PATCH 12/12] docs(gst): drop a period count that described the taxpayer, not the defect The comment named how many periods one live run found unavailable, which is a fact about that taxpayer's returns rather than about the persistence bug it was explaining. The bug is that every declined period came back as needs-review; the count was never part of it. --- .../filed-returns-flow-gstr2b-not-generated.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts index f8087726..1717fe95 100644 --- a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts +++ b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts @@ -148,9 +148,9 @@ describe("recovering from a stale refusal", () => { describe("the signals a terminal step leaves behind", () => { // A terminal step's signals are persisted. One token the allowlist has never been told about - // rejects the entire array, which blocks the target -- so a live run recorded "needs review" for - // all seven periods the portal had plainly refused. The transient "ready" step carried the same - // token harmlessly for months, because its signals never reach durable state. + // rejects the entire array, which blocks the target -- so every period the portal declined came + // back as "needs review" instead of as the answer it was. The transient "ready" step carried the + // same token harmlessly for months, because its signals never reach durable state. it.each([ ["the refusal it records", "April"], ["the stale page it walks away from", "May"],