GSTR-2B- AUTO-DRAFTED ITC STATEMENT
+Financial Year - ${financialYear}
+Return Period - ${period}
+Generation date -
+diff --git a/docs/PORTAL_INTEGRATION_FINDINGS.md b/docs/PORTAL_INTEGRATION_FINDINGS.md index 127cdbcd..c5d1064e 100644 --- a/docs/PORTAL_INTEGRATION_FINDINGS.md +++ b/docs/PORTAL_INTEGRATION_FINDINGS.md @@ -445,3 +445,64 @@ in page copy is not a control: decoy or non-actionable text carrying the same wo suppress a real `View Summary` step and strand a target whose PDF was reachable. **Not yet established.** Which periods render which shape, and whether a page can offer both. + +## 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 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: + +``` +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: 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 +`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. 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( 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 da1521c0..17170921 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -115,7 +115,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", diff --git a/src/connectors/gst/gstr2b-flow.ts b/src/connectors/gst/gstr2b-flow.ts index 21b96a47..960f3be0 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,43 @@ 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". + * + * `requireVisibleEvidence` is the difference between them, and it is the refusal that sets it: + * see `verifyVisibleGstr2bPeriod`. + */ +function leaveUnlessVisiblePeriodMatches( + documentRef: Document, + normalisedText: string, + scope: FiledReturnsDownloadScope, + scopeId: string, + safeSignals: readonly string[], + requireVisibleEvidence = false, +): PortalFlowStepResult | null { + const periodGuard = verifyVisibleGstr2bPeriod( + documentRef, + normalisedText, + scope, + requireVisibleEvidence, + ); + if (!periodGuard) return null; + return ( + returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ + ...safeSignals, + ...periodGuard.safeSignals, + ]) ?? periodGuard + ); +} const FILED_RETURNS_ROUTE = /\/returns\/auth\/efiledReturns\/?$/i; @@ -73,6 +111,45 @@ 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, + true, + ); + 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 +158,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..035aedfd 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 ( @@ -53,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"); @@ -80,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 { 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) ? ( 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"); + }); +}); 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..1717fe95 --- /dev/null +++ b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts @@ -0,0 +1,169 @@ +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( + ` +Financial Year - ${financialYear}
+Return Period - ${period}
+Generation date -
+