From 9d29c1b5f6a0be77b267ee444b98d7ab626b9c10 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:22:25 +0530 Subject: [PATCH 1/7] fix(gst): record a period the portal declined to generate The portal drafts the GSTR-2B statement rather than the taxpayer filing it, and for a period with no inward supplies it drafts nothing. Recording that as not-filed would print a claim about the taxpayer the portal never made, so it becomes its own resolved status. Twelve hand-kept copies of the target-status lists decided what a run may persist, and none of them learned about the new status. A saved plan therefore failed to parse the moment a period used it, which threw out of the background handler and took the whole run's details with it. A Set literal is not exhaustiveness-checked, so nothing failed to compile. All twelve now derive from the one list that defines them. Nine of them were the same fact under nine names and are gone entirely: every consumer asked .has(status), which the shared predicate answers. The recoverable set turned out to be the exact complement of resolved, so it derives too rather than being restated and drifting. Both fiscal-year ZIP planners asked the same four-branch question about what a period earns in the summary; a period the portal declined defaulted to staged, becoming a file the run then reported itself as having failed to produce and blocking the ZIP for a whole year on it. One named rule now answers it for both. --- ...s-all-supported-full-fiscal-year-ledger.ts | 36 +++---- ...-all-supported-full-fiscal-year-summary.ts | 9 +- ...l-supported-full-fiscal-year-validation.ts | 25 ++--- ...urns-all-supported-full-fiscal-year-zip.ts | 12 +-- ...-returns-all-supported-full-fiscal-year.ts | 15 ++- .../filed-returns-durable-summary.ts | 14 +-- .../filed-returns-full-fiscal-year-ledger.ts | 18 ++-- ...filed-returns-full-fiscal-year-recovery.ts | 13 +-- .../filed-returns-full-fiscal-year-summary.ts | 14 +-- ...led-returns-full-fiscal-year-validation.ts | 27 ++--- .../filed-returns-full-fiscal-year-zip.ts | 12 +-- .../filed-returns-full-fiscal-year.ts | 9 +- ...led-returns-single-period-bundle-ledger.ts | 21 ++-- src/connectors/gst/filed-returns-contracts.ts | 57 ++++++++-- .../gst/filed-returns-durable-status.ts | 6 ++ .../filed-returns-post-click-blocked-state.ts | 101 +++++++++++++----- .../gst/filed-returns-summary-sheet.ts | 24 ++++- src/connectors/gst/offscreen-blob-url.ts | 7 +- src/entrypoints/popup/target-evidence.tsx | 4 + 19 files changed, 255 insertions(+), 169 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts b/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts index f84ac933..c95baf2f 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts @@ -3,6 +3,7 @@ import type { FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import { ALL_SUPPORTED_FULL_FISCAL_YEAR_CATALOGUE_VERSION, expandAllSupportedFullFiscalYearTargetPlan, @@ -29,17 +30,6 @@ import { type FiledReturnsAllSupportedFullFiscalYearTarget, } from "./filed-returns-all-supported-full-fiscal-year-validation"; -const POSITIVE_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); -const EXPLICIT_RETRY_TARGET_STATUSES = new Set([ - "download-unconfirmed", - "blocked", - "failed", - "cancelled", - "manually-observed", -]); const NON_RESUMABLE_EXPLICIT_RETRY_SIGNALS = new Set([ "all-supported-full-fiscal-year-artifact-snapshot-mismatch", "full-fiscal-year-pinned-gst-tab-unavailable", @@ -64,7 +54,7 @@ export function allSupportedExplicitRetryTarget( const target = ledger.targets[targetIndex]!; return ledger.targets .slice(0, targetIndex) - .every((candidate) => POSITIVE_TARGET_STATUSES.has(candidate.status)) && + .every((candidate) => isResolvedFullFiscalYearTargetStatus(candidate.status)) && ledger.targets.slice(targetIndex + 1).every((candidate) => candidate.status === "pending") ? target : null; @@ -74,7 +64,9 @@ function isExplicitlyRetryableTarget( target: FiledReturnsAllSupportedFullFiscalYearTarget, ): boolean { return ( - EXPLICIT_RETRY_TARGET_STATUSES.has(target.status) && + !isResolvedFullFiscalYearTargetStatus(target.status) && + target.status !== "pending" && + target.status !== "running" && !target.safeSignals.some((signal) => NON_RESUMABLE_EXPLICIT_RETRY_SIGNALS.has(signal)) ); } @@ -118,8 +110,9 @@ export function allSupportedResumeIsProductive( return !ledger.targets.some((target) => target.status === "running"); } if (ledger.status === "partial") { - return ledger.targets.every((target) => - ["pending", ...POSITIVE_TARGET_STATUSES].includes(target.status), + return ledger.targets.every( + (target) => + target.status === "pending" || isResolvedFullFiscalYearTargetStatus(target.status), ); } return false; @@ -335,7 +328,7 @@ export function canCompleteAllSupportedFullFiscalYearLedger( ): boolean { return ( ledger.targets.length > 0 && - ledger.targets.every((target) => POSITIVE_TARGET_STATUSES.has(target.status)) + ledger.targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status)) ); } @@ -406,7 +399,9 @@ export function markAllSupportedFullFiscalYearTargetTerminal( status: effectiveStatus, ...canonicalDurableTargetStatus(targetScope(target), effectiveStatus, inputSignals), ...(diagnosticState ?? {}), - ...(POSITIVE_TARGET_STATUSES.has(effectiveStatus) ? { completedAt: timestamp } : {}), + ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) + ? { completedAt: timestamp } + : {}), updatedAt: timestamp, } : target, @@ -422,7 +417,7 @@ export function markAllSupportedFullFiscalYearTargetTerminal( // `currentTargetId` is a recovery pointer, not a record of the last write. // Leaving it on a completed target made an interrupted worker window name a // return that had already succeeded as the affected target. - if (POSITIVE_TARGET_STATUSES.has(effectiveStatus)) delete terminal.currentTargetId; + if (isResolvedFullFiscalYearTargetStatus(effectiveStatus)) delete terminal.currentTargetId; return terminal; } @@ -481,9 +476,10 @@ function ledgerStatus( targets: readonly FiledReturnsAllSupportedFullFiscalYearTarget[], lastStatus: FiledReturnsFullFiscalYearTargetStatus, ): FiledReturnsAllSupportedFullFiscalYearLedger["status"] { - if (targets.every((target) => POSITIVE_TARGET_STATUSES.has(target.status))) return "complete"; + if (targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status))) + return "complete"; if (lastStatus === "cancelled") return "cancelled"; - if (lastStatus === "manually-observed" || POSITIVE_TARGET_STATUSES.has(lastStatus)) + if (lastStatus === "manually-observed" || isResolvedFullFiscalYearTargetStatus(lastStatus)) return "partial"; return "blocked"; } 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 59162750..b65c41b3 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 @@ -2,9 +2,9 @@ import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearTargetEvidence, FiledReturnsDownloadScope, - FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import { filedReturnScopeId } from "../connectors/gst/filed-returns-return-descriptors"; import { allSupportedExplicitRetryTarget, @@ -22,11 +22,6 @@ import type { FiledReturnsAllSupportedFullFiscalYearTarget, } from "./filed-returns-all-supported-full-fiscal-year-validation"; -const POSITIVE_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); - export interface AllSupportedFullFiscalYearCurrentStateDeps { storageKeys: { allSupportedFullFiscalYearLedgerIndex?: string; activeRun?: string }; now?: () => Date; @@ -129,7 +124,7 @@ export function toAllSupportedFullFiscalYearSummary( ...(ledger.status === "complete" ? { completedAt: ledger.updatedAt } : {}), updatedAt: ledger.updatedAt, completedTargetIds: ledger.targets - .filter((target) => POSITIVE_TARGET_STATUSES.has(target.status)) + .filter((target) => isResolvedFullFiscalYearTargetStatus(target.status)) .map((target) => target.targetId), targetEvidence: ledger.targets.map((target) => ({ targetId: target.targetId, diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts b/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts index ba98673f..1ec631e7 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts @@ -3,6 +3,10 @@ import type { FiledReturnsDownloadDiagnostic, FiledReturnsFullFiscalYearTargetStatus, } from "../connectors/gst/filed-returns-contracts"; +import { + isResolvedFullFiscalYearTargetStatus, + isFiledReturnsFullFiscalYearTargetStatus, +} from "../connectors/gst/filed-returns-contracts"; import type { FiledReturnsArtifactType, FiledReturnsConcreteArtifactType, @@ -119,21 +123,6 @@ export type AllSupportedFullFiscalYearZipPhase = | "cleaned"; const MAX_SAFE_MESSAGE_LENGTH = 500; -const TARGET_STATUSES = new Set([ - "pending", - "running", - "downloaded", - "manually-observed", - "not-filed", - "download-unconfirmed", - "blocked", - "failed", - "cancelled", -]); -const POSITIVE_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); const ZIP_PHASES = new Set([ "export-pending", "export-retry-pending", @@ -281,7 +270,7 @@ export function isAllSupportedFullFiscalYearLedger( return !( ledger.zipPhase && ZIP_PHASES_REQUIRING_COMPLETED_TARGETS.has(ledger.zipPhase) && - !ledger.targets.every((target) => POSITIVE_TARGET_STATUSES.has(target.status)) + !ledger.targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status)) ); } @@ -491,7 +480,9 @@ function isTarget( target.returnType !== planTarget.returnType || target.artifactType !== planTarget.artifactType || !sameArtifacts(target.concreteArtifactTypes, planTarget.concreteArtifactTypes) || - !TARGET_STATUSES.has(target.status as FiledReturnsFullFiscalYearTargetStatus) || + !isFiledReturnsFullFiscalYearTargetStatus( + target.status as FiledReturnsFullFiscalYearTargetStatus, + ) || !isAttemptCount(target.attempts) || !isBoundedString(target.safeMessage, 1, MAX_SAFE_MESSAGE_LENGTH) || !isCanonicalTimestamp(target.updatedAt) || diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-zip.ts b/src/background/filed-returns-all-supported-full-fiscal-year-zip.ts index 4c2a7834..8c77f1fa 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-zip.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-zip.ts @@ -2,6 +2,7 @@ import { browser } from "wxt/browser"; import type { PortalFlowStepResult } from "../connectors/gst/filed-returns-contracts"; import type { PackOffscreenFiledReturnZipExpectedEntry } from "../connectors/gst/offscreen-blob-url"; import type { FiledReturnsSummaryPlanEntry } from "../connectors/gst/filed-returns-summary-sheet"; +import { filedReturnsSummaryOutcomeCategory } from "../connectors/gst/filed-returns-summary-sheet"; import type { FiledReturnsSummaryStatus } from "../connectors/gst/filed-returns-summary-status"; import { canCompleteAllSupportedFullFiscalYearLedger } from "./filed-returns-all-supported-full-fiscal-year-ledger"; import { @@ -212,12 +213,11 @@ function allSupportedFullFiscalYearStagingRequirement( safeAllSupportedFullFiscalYearZipEntryPath(target, artifactType, ".xlsx"), ]; } - const outcomeCategory = - target.status === "not-filed" - ? "not-filed" - : signals.has(`filed-return-artifact-unavailable:${artifactType}`) - ? "artifact-unavailable" - : "staged"; + const outcomeCategory = filedReturnsSummaryOutcomeCategory( + target.status, + signals, + artifactType, + ); summaryPlan.push({ artifactType, entryNames: outcomeCategory === "staged" ? expectedEntry.entryNames : [], 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 446fd3a8..3813a534 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year.ts @@ -2,9 +2,9 @@ import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearRequest, FiledReturnsDownloadScope, - FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import { concreteFiledReturnsArtifactTypesForSelection } from "../connectors/gst/filed-returns-artifacts"; import { expandAllSupportedFullFiscalYearTargetPlan, @@ -61,10 +61,6 @@ type AllSupportedRunnerDeps = FiledReturnsFlowRunnerDeps & { type SystemErrorPredecessor = FiledReturnsFlowStepCategory | "initial"; -const POSITIVE_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); const MAX_DURABLE_FLOW_SIGNALS = 32; /** @@ -377,8 +373,9 @@ async function continueSavedAllSupportedFullFiscalYearRun( } if ( ledger.status === "partial" && - ledger.targets.every((target) => - ["pending", ...POSITIVE_TARGET_STATUSES].includes(target.status), + ledger.targets.every( + (target) => + target.status === "pending" || isResolvedFullFiscalYearTargetStatus(target.status), ) ) { return runAllSupportedFullFiscalYearTargets(deps, ledger, runSinglePeriod); @@ -568,7 +565,7 @@ async function runAllSupportedFullFiscalYearTargets( const persistedTarget = ledger.targets.find( (target) => target.targetId === nextTarget.targetId, ); - if (persistedTarget && POSITIVE_TARGET_STATUSES.has(persistedTarget.status)) continue; + if (persistedTarget && isResolvedFullFiscalYearTargetStatus(persistedTarget.status)) continue; return allSupportedResponse(deps, ledger, flowStep); } } @@ -717,7 +714,7 @@ function toAllSupportedSummary( ...(ledger.status === "complete" ? { completedAt: ledger.updatedAt } : {}), updatedAt: ledger.updatedAt, completedTargetIds: ledger.targets - .filter((target) => POSITIVE_TARGET_STATUSES.has(target.status)) + .filter((target) => isResolvedFullFiscalYearTargetStatus(target.status)) .map((target) => target.targetId), targetEvidence: ledger.targets.map((target) => ({ targetId: target.targetId, diff --git a/src/background/filed-returns-durable-summary.ts b/src/background/filed-returns-durable-summary.ts index 1a8b4fb2..b3a0632d 100644 --- a/src/background/filed-returns-durable-summary.ts +++ b/src/background/filed-returns-durable-summary.ts @@ -29,6 +29,7 @@ import { hasPositiveFiledReturnsDownloadEvidence, isValidFiledReturnsDownloadDiagnosticState, } from "./filed-returns-download-diagnostic-state"; +import { isFiledReturnsFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; const SUMMARY_KEYS = [ "artifactAcquisitionCompletion", @@ -81,17 +82,6 @@ const FLOW_STATES = new Set([ "unsupported-page", "user-action-required", ]); -const TARGET_STATUSES = new Set([ - "blocked", - "cancelled", - "download-unconfirmed", - "downloaded", - "failed", - "manually-observed", - "not-filed", - "pending", - "running", -]); export function parseDurableFiledReturnsFlowSummary( input: unknown, @@ -357,7 +347,7 @@ function parseRecovery( } if ( typeof recovery.targetStatus !== "string" || - !TARGET_STATUSES.has(recovery.targetStatus as FiledReturnsFullFiscalYearTargetStatus) + !isFiledReturnsFullFiscalYearTargetStatus(recovery.targetStatus) ) { return null; } diff --git a/src/background/filed-returns-full-fiscal-year-ledger.ts b/src/background/filed-returns-full-fiscal-year-ledger.ts index 9534bfe3..ecb3d03a 100644 --- a/src/background/filed-returns-full-fiscal-year-ledger.ts +++ b/src/background/filed-returns-full-fiscal-year-ledger.ts @@ -6,6 +6,7 @@ import type { FiledReturnsLedgerPlanTarget, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import { normaliseFiledReturnsArtifactType, type FiledReturnsArtifactType, @@ -34,10 +35,6 @@ export { } from "./filed-returns-full-fiscal-year-validation"; const ACTIVE_LEDGER_STALE_MS = 30_000; -const POSITIVE_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); export function createFullFiscalYearLedger( scope: FiledReturnsDownloadScope, @@ -230,7 +227,7 @@ export function canCompleteFullFiscalYearLedger(ledger: FiledReturnsFullFiscalYe return ( hasCanonicalFullFiscalYearTargetPlan(ledger) && ledger.targets.length > 0 && - ledger.targets.every((target) => POSITIVE_TARGET_STATUSES.has(target.status)) + ledger.targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status)) ); } @@ -240,7 +237,7 @@ export function hasInconsistentFullFiscalYearCompletion( ): boolean { return ( ledger.status === "complete" && - ledger.targets.some((target) => !POSITIVE_TARGET_STATUSES.has(target.status)) + ledger.targets.some((target) => !isResolvedFullFiscalYearTargetStatus(target.status)) ); } @@ -248,7 +245,7 @@ export function hasActionRequiredFullFiscalYearTarget( ledger: FiledReturnsFullFiscalYearLedger, ): boolean { return ledger.targets.some( - (target) => target.status !== "pending" && !POSITIVE_TARGET_STATUSES.has(target.status), + (target) => target.status !== "pending" && !isResolvedFullFiscalYearTargetStatus(target.status), ); } @@ -347,7 +344,7 @@ export function markFullFiscalYearTargetTerminal( ["filed-return-durable-status-rejected"], )), ...(diagnosticState ?? {}), - ...(POSITIVE_TARGET_STATUSES.has(effectiveStatus) ? { completedAt: timestamp } : {}), + ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) ? { completedAt: timestamp } : {}), updatedAt: timestamp, }; }); @@ -434,10 +431,11 @@ function ledgerStatus( targets: readonly FiledReturnsFullFiscalYearTarget[], lastStatus: FiledReturnsFullFiscalYearTargetStatus, ): FiledReturnsFullFiscalYearLedger["status"] { - if (targets.every((target) => POSITIVE_TARGET_STATUSES.has(target.status))) return "complete"; + if (targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status))) + return "complete"; if (lastStatus === "cancelled") return "cancelled"; if (lastStatus === "manually-observed") return "partial"; - if (POSITIVE_TARGET_STATUSES.has(lastStatus)) return "partial"; + if (isResolvedFullFiscalYearTargetStatus(lastStatus)) return "partial"; return "blocked"; } diff --git a/src/background/filed-returns-full-fiscal-year-recovery.ts b/src/background/filed-returns-full-fiscal-year-recovery.ts index c9d4e16b..ee896796 100644 --- a/src/background/filed-returns-full-fiscal-year-recovery.ts +++ b/src/background/filed-returns-full-fiscal-year-recovery.ts @@ -4,9 +4,9 @@ import type { FiledReturnsFlowSummary, FiledReturnsFullFiscalYearLedger, FiledReturnsFullFiscalYearTarget, - FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import type { FullFiscalYearTargetRecoveryPayload, PackMessageResponse, @@ -25,15 +25,6 @@ import { removeLedger as removePlanLedger, } from "./filed-returns-full-fiscal-year-run-state"; -const RECOVERABLE_TARGET_STATUSES = new Set([ - "pending", - "download-unconfirmed", - "running", - "blocked", - "failed", - "cancelled", - "manually-observed", -]); const FINAL_SIDE_EFFECT_SIGNALS = new Set([ "filed-return-download-clicked", "filed-return-download-trigger-ambiguous", @@ -285,7 +276,7 @@ async function readRecoverableFullFiscalYearTarget( }; } - if (!RECOVERABLE_TARGET_STATUSES.has(target.status)) { + if (isResolvedFullFiscalYearTargetStatus(target.status)) { return { response: recoveryActionUnavailableResponse( "full-fiscal-year-target-not-recoverable", diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index eae18533..044dfdaf 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -8,6 +8,7 @@ import type { PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; import { + isResolvedFullFiscalYearTargetStatus, isCleanedZipPhase, zipPhaseProvesDelivery, } from "../connectors/gst/filed-returns-contracts"; @@ -121,11 +122,6 @@ const RUN_INDETERMINATE_SIGNALS: readonly string[] = [ "filed-returns-active-run-malformed", ]; -const COMPLETED_SUMMARY_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); - /** * The nine internal statuses collapsed to what a reader is deciding about. * @@ -141,6 +137,7 @@ const TARGET_OUTCOMES: Readonly< // OPFS, not delivered to the browser. downloaded: "saved", "not-filed": "not-filed", + "not-generated": "not-generated", // A person reporting what they saw is not correlated download evidence, so // this sits with the failures rather than with `saved`. "manually-observed": "needs-review", @@ -163,6 +160,11 @@ export function targetStatusFromFlowStep( if (step.safeSignals.includes("filed-return-positively-not-filed")) { return "not-filed"; } + // The portal stated it produced nothing for this period. A positive answer, like + // `filed-return-positively-not-filed` above -- not an inability to determine. + if (step.safeSignals.includes("filed-gstr2b-not-generated")) { + return "not-generated"; + } if (step.safeSignals.some(isUnconfirmedBrowserDownloadSignal)) { return "download-unconfirmed"; } @@ -388,7 +390,7 @@ export function toFullFiscalYearSummary( ): FiledReturnsFlowSummary { ledger = recoveryLedgerView(ledger); const completedPeriods = ledger.targets - .filter((target) => COMPLETED_SUMMARY_TARGET_STATUSES.has(target.status)) + .filter((target) => isResolvedFullFiscalYearTargetStatus(target.status)) .map((target) => target.period); const recoveryTarget = fullFiscalYearRecoveryTarget( ledger, diff --git a/src/background/filed-returns-full-fiscal-year-validation.ts b/src/background/filed-returns-full-fiscal-year-validation.ts index 1747f305..3b7c74fe 100644 --- a/src/background/filed-returns-full-fiscal-year-validation.ts +++ b/src/background/filed-returns-full-fiscal-year-validation.ts @@ -2,10 +2,14 @@ import type { FiledReturnsDownloadScope, FiledReturnsFullFiscalYearLedger, FiledReturnsFullFiscalYearTarget, - FiledReturnsFullFiscalYearTargetStatus, FiledReturnsLedgerPlanTarget, } from "../connectors/gst/filed-returns-contracts"; -import { isCleanedZipPhase, CLEANED_ZIP_PHASES } from "../connectors/gst/filed-returns-contracts"; +import { + isResolvedFullFiscalYearTargetStatus, + isFiledReturnsFullFiscalYearTargetStatus, + isCleanedZipPhase, + CLEANED_ZIP_PHASES, +} from "../connectors/gst/filed-returns-contracts"; import { isFiledReturnsArtifactType, normaliseFiledReturnsArtifactType, @@ -119,17 +123,6 @@ const VALID_LEDGER_STATUSES = new Set([ - "pending", - "running", - "downloaded", - "manually-observed", - "not-filed", - "download-unconfirmed", - "blocked", - "failed", - "cancelled", -]); const VALID_ZIP_PHASES = new Set>([ "export-pending", "export-retry-pending", @@ -155,10 +148,6 @@ const ZIP_PHASES_REQUIRING_COMPLETED_TARGETS = new Set< "legacy-cleanup-pending", ...CLEANED_ZIP_PHASES, ]); -const COMPLETED_TARGET_STATUSES = new Set([ - "downloaded", - "not-filed", -]); const LEDGER_KEYS = [ "connectorVersion", "createdAt", @@ -268,7 +257,7 @@ export function isFullFiscalYearLedger(input: unknown): input is FiledReturnsFul ledger.zipPhase && ZIP_PHASES_REQUIRING_COMPLETED_TARGETS.has(ledger.zipPhase) && (ledger.targets.length === 0 || - !ledger.targets.every((target) => COMPLETED_TARGET_STATUSES.has(target.status))) + !ledger.targets.every((target) => isResolvedFullFiscalYearTargetStatus(target.status))) ) { return false; } @@ -385,7 +374,7 @@ function isFullFiscalYearTarget( if (target.targetId !== createTargetId(financialYear, period, returnType, artifactType)) { return false; } - if (!target.status || !VALID_TARGET_STATUSES.has(target.status)) return false; + if (!target.status || !isFiledReturnsFullFiscalYearTargetStatus(target.status)) return false; const attempts = target.attempts; if ( typeof attempts !== "number" || diff --git a/src/background/filed-returns-full-fiscal-year-zip.ts b/src/background/filed-returns-full-fiscal-year-zip.ts index f40e4b01..80485a0b 100644 --- a/src/background/filed-returns-full-fiscal-year-zip.ts +++ b/src/background/filed-returns-full-fiscal-year-zip.ts @@ -6,6 +6,7 @@ import type { import { concreteFiledReturnsArtifactTypesForSelection } from "../connectors/gst/filed-returns-artifacts"; import type { PackOffscreenFiledReturnZipExpectedEntry } from "../connectors/gst/offscreen-blob-url"; import type { FiledReturnsSummaryPlanEntry } from "../connectors/gst/filed-returns-summary-sheet"; +import { filedReturnsSummaryOutcomeCategory } from "../connectors/gst/filed-returns-summary-sheet"; import type { FiledReturnsSummaryStatus } from "../connectors/gst/filed-returns-summary-status"; import type { FiledReturnsMonth } from "../connectors/gst/filed-returns-scope"; import { @@ -268,12 +269,11 @@ function fullFiscalYearStagingRequirement(ledger: FiledReturnsFullFiscalYearLedg [artifactType], ); if (!expectedEntry) continue; - const outcomeCategory = - target.status === "not-filed" - ? "not-filed" - : signals.has(`filed-return-artifact-unavailable:${artifactType}`) - ? "artifact-unavailable" - : "staged"; + const outcomeCategory = filedReturnsSummaryOutcomeCategory( + target.status, + signals, + artifactType, + ); summaryPlan.push({ artifactType, entryNames: outcomeCategory === "staged" ? expectedEntry.entryNames : [], diff --git a/src/background/filed-returns-full-fiscal-year.ts b/src/background/filed-returns-full-fiscal-year.ts index 6acf773a..d808422d 100644 --- a/src/background/filed-returns-full-fiscal-year.ts +++ b/src/background/filed-returns-full-fiscal-year.ts @@ -4,6 +4,7 @@ import type { FiledReturnsFullFiscalYearLedger, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import type { PackMessageResponse } from "../connectors/gst/messages"; import { getFiledReturnsFullFiscalYearPeriods } from "../connectors/gst/filed-returns-scope"; import { filedReturnsSummaryStatusMessage } from "../connectors/gst/filed-returns-summary-status"; @@ -436,14 +437,14 @@ export async function startFullFiscalYearDownloadFlow( deps.now?.() ?? new Date(), ); if ( - (targetStatus === "downloaded" || targetStatus === "not-filed") && + isResolvedFullFiscalYearTargetStatus(targetStatus) && canCompleteFullFiscalYearLedger(ledger) ) { ledger = markFullFiscalYearZipPhase(ledger, deps.now?.() ?? new Date(), "export-pending"); } await persistLedgerAndMaybeSummary(deps, ledger, flowStep); - if (targetStatus === "downloaded" || targetStatus === "not-filed") continue; + if (isResolvedFullFiscalYearTargetStatus(targetStatus)) continue; const flowSummary = toFullFiscalYearSummary(ledger, flowStep); if (targetStatus !== "download-unconfirmed") { await persistSummary(deps, flowSummary); @@ -452,6 +453,10 @@ export async function startFullFiscalYearDownloadFlow( } } +// A target the run does not need to stop for: the portal answered, and the answer is final for +// this period. `not-generated` belongs here for the same reason `not-filed` does -- a period the +// portal produced nothing for cannot be fixed by pausing the run and asking someone to look at it. + async function completeRun( deps: FiledReturnsFlowRunnerDeps, ledger: FiledReturnsFullFiscalYearLedger, diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index dd3b2f57..9d729925 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -21,7 +21,16 @@ import { import { filedReturnScopeId } from "../connectors/gst/filed-returns-return-descriptors"; import { isValidFiledReturnsDownloadDiagnosticState } from "./filed-returns-download-diagnostic-state"; import { PACK_LOCAL_STORAGE_KEYS } from "./storage-keys"; -const MISSING_ARTIFACT_REASONS = new Set(["artifact-filed-gstr1-excel-no-details-available"]); +const MISSING_ARTIFACT_REASONS = new Set([ + "artifact-filed-gstr1-excel-no-details-available", + "artifact-filed-gstr2b-not-generated", +]); + +// The flow signal the portal's refusal carries, and the reason recorded against the artifact. +const DECLINED_ARTIFACT_REASONS = new Map([ + ["filed-gstr1-excel-no-details-available", "artifact-filed-gstr1-excel-no-details-available"], + ["filed-gstr2b-not-generated", "artifact-filed-gstr2b-not-generated"], +]); const LEDGER_KEYS = [ "artifactPlan", @@ -854,12 +863,10 @@ function parsedArtifactPlan( } function missingArtifactReason(flowStep: PortalFlowStepResult): string | null { - return ( - flowStep.safeSignals.find((signal) => MISSING_ARTIFACT_REASONS.has(signal)) ?? - (flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available") - ? "artifact-filed-gstr1-excel-no-details-available" - : null) - ); + const recorded = flowStep.safeSignals.find((signal) => MISSING_ARTIFACT_REASONS.has(signal)); + if (recorded) return recorded; + const declined = flowStep.safeSignals.find((signal) => DECLINED_ARTIFACT_REASONS.has(signal)); + return declined ? (DECLINED_ARTIFACT_REASONS.get(declined) ?? null) : null; } function isMissingReason(value: unknown): value is string { diff --git a/src/connectors/gst/filed-returns-contracts.ts b/src/connectors/gst/filed-returns-contracts.ts index 4a8a9d90..8ba7a213 100644 --- a/src/connectors/gst/filed-returns-contracts.ts +++ b/src/connectors/gst/filed-returns-contracts.ts @@ -227,16 +227,52 @@ export interface FiledReturnsDownloadDiagnostic { errorCategory?: string; } +// One list, and the type derived from it. A `Set` literal +// does not have to be exhaustive, so a status added to a union alone can pass type-checking while +// a runtime allowlist elsewhere silently rejects it -- which is how a persisted run summary became +// unparseable and took its whole run with it. +export const FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES = [ + "pending", + "running", + "downloaded", + "manually-observed", + "not-filed", + // The portal states there is no artifact for this period, as distinct from a taxpayer not + // having submitted one. The auto-drafted GSTR-2B statement is drafted by the portal, never + // submitted by the taxpayer, so recording it as unfiled would print a claim about them that + // the portal never made. + "not-generated", + "download-unconfirmed", + "blocked", + "failed", + "cancelled", +] as const; + export type FiledReturnsFullFiscalYearTargetStatus = - | "pending" - | "running" - | "downloaded" - | "manually-observed" - | "not-filed" - | "download-unconfirmed" - | "blocked" - | "failed" - | "cancelled"; + (typeof FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES)[number]; + +/** Membership in the list above, so a validator cannot be told a status the list already allows. */ +export function isFiledReturnsFullFiscalYearTargetStatus( + value: unknown, +): value is FiledReturnsFullFiscalYearTargetStatus { + return (FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES as readonly unknown[]).includes(value); +} + +// A target the portal has answered for. Retrying one cannot change its outcome, so a run counts it +// as done. Nine modules each kept their own copy of this pair, every copy spelled `downloaded` and +// `not-filed`, and none of them learned about `not-generated` -- which is how a live run stopped on +// the first period the portal declined to draft. +const RESOLVED_TARGET_STATUSES = new Set([ + "downloaded", + "not-filed", + "not-generated", +]); + +export function isResolvedFullFiscalYearTargetStatus( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + return RESOLVED_TARGET_STATUSES.has(status); +} export interface FiledReturnsFullFiscalYearTarget { targetId: string; @@ -374,6 +410,9 @@ export type FiledReturnsTargetOutcome = // offered is not a fault a re-run corrects, and routing it to review would // send someone looking for a problem that is not theirs. | "partly-saved" + // The portal never drafted anything for this period. Separate from `not-filed`, which says + // something about the taxpayer, and from `needs-review`, which says a re-run might help. + | "not-generated" | "captured" | "not-filed" | "needs-review" diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index 63ec33d2..bc7e8149 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -1,3 +1,4 @@ +import { GSTR2B_NOT_GENERATED_SAFE_MESSAGE } from "./filed-returns-post-click-blocked-state"; import { FILED_RETURNS_FILTER_DEADLINE_EXPIRED_MESSAGE, filedReturnsFilterActionRequiredMessage, @@ -56,6 +57,7 @@ type DurableMessageKey = | "full-year-tab-session-unavailable" | "full-year-zip-review" | "not-filed" + | "not-generated" | "partial" | "target-cancelled" | "target-blocked" @@ -476,6 +478,7 @@ function messageKeyForTarget( if (signals.includes("filed-return-positively-not-filed") || status === "not-filed") { return "not-filed"; } + if (status === "not-generated") return "not-generated"; if (status === "pending") return "target-pending"; if (status === "running") return "target-running"; if (status === "downloaded") return "target-downloaded"; @@ -647,6 +650,9 @@ function renderDurableMessage(key: DurableMessageKey, scope: FiledReturnsDownloa "full-year-zip-review": "Pack could not confirm the final fiscal-year ZIP. Check the exact browser download before retrying.", "not-filed": "The GST Portal reported no filed return for the selected period.", + // The portal declined to produce the artifact, in its own words. Retrying cannot change that, + // so the copy must not send the user to Downloads looking for a file that was never created. + "not-generated": GSTR2B_NOT_GENERATED_SAFE_MESSAGE, partial: `Pack retained verified artifact progress for ${period}; the selection is not complete.`, "target-cancelled": `Pack cancelled the unresolved filed-return target for ${period}.`, "target-blocked": `Pack paused the saved full-year run at ${period}. Resolve the GST Portal page before retrying this period.`, 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 c2bd3f02..761bdcf9 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -1,26 +1,26 @@ import type { PortalDownloadTriggerResult } from "../../core/contracts"; -import { delay } from "../../core/time"; import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; -const GSTR1_EXCEL_POST_CLICK_BLOCKED_WAIT_MS = 800; -const GSTR1_EXCEL_POST_CLICK_BLOCKED_POLL_MS = 100; +// The portal declining to produce an artifact, in its own words. +// +// Two of these are captured live and neither is a failure. A filed GSTR-1 has no details workbook +// when the taxpayer reports no e-invoices. Separately, the portal may not draft the GSTR-2B +// statement for a period at all; the portal drafts that statement rather than the taxpayer +// submitting it. Both are answers. Retrying cannot change either within a run, and treating them +// as faults leaves the run offering a remedy that cannot work. +// +// Recognising a declined artifact records its absence with the portal's own reason. It never +// records a download: every result here is `blocked`, and completion still requires correlated +// download evidence. -export async function waitForPostClickBlockedState( - documentRef: Document, - target: FiledReturnsDownloadTarget, - safeSignals: string[], -): Promise { - if (target.returnType !== "GSTR-1" || target.artifactType !== "EXCEL") return null; - - const startedAt = Date.now(); - do { - const blockedState = detectPostClickBlockedState(documentRef, target, safeSignals); - if (blockedState) return blockedState; - await delay(GSTR1_EXCEL_POST_CLICK_BLOCKED_POLL_MS); - } while (Date.now() - startedAt < GSTR1_EXCEL_POST_CLICK_BLOCKED_WAIT_MS); +function normalisedPageText(documentRef: Document): string { + const text = documentRef.body?.innerText ?? documentRef.body?.textContent ?? ""; + return text.replace(/\s+/g, " ").trim(); +} - return null; +function withSignal(safeSignals: string[], signal: string): string[] { + return safeSignals.includes(signal) ? [...safeSignals] : [...safeSignals, signal]; } export function detectPostClickBlockedState( @@ -28,10 +28,21 @@ export function detectPostClickBlockedState( target: FiledReturnsDownloadTarget, safeSignals: string[], ): PortalDownloadTriggerResult | null { - if (target.returnType !== "GSTR-1" || target.artifactType !== "EXCEL") return null; + const normalised = normalisedPageText(documentRef); + if (target.returnType === "GSTR-1" && target.artifactType === "EXCEL") { + return detectGstr1ExcelNoDetails(normalised, target, safeSignals); + } + if (target.returnType === "GSTR-2B") { + return detectGstr2bNotGenerated(normalised, target, safeSignals); + } + return null; +} - const text = documentRef.body?.innerText ?? documentRef.body?.textContent ?? ""; - const normalised = text.replace(/\s+/g, " ").trim(); +function detectGstr1ExcelNoDetails( + normalised: string, + target: FiledReturnsDownloadTarget, + safeSignals: string[], +): PortalDownloadTriggerResult | null { if ( !/\bno\s+details\s+available\s+for\s+download\b/i.test(normalised) || !/\be-?invoices?\b/i.test(normalised) @@ -43,12 +54,7 @@ export function detectPostClickBlockedState( connectorId: "gst", scopeId: filedReturnScopeId(target.returnType), state: "blocked", - safeSignals: [ - ...safeSignals, - ...(safeSignals.includes("filed-gstr1-excel-no-details-available") - ? [] - : ["filed-gstr1-excel-no-details-available"]), - ], + safeSignals: withSignal(safeSignals, "filed-gstr1-excel-no-details-available"), safeMessage: "The GST Portal reported that no e-invoice details are available for this filed GSTR-1 period, so Pack did not record an Excel download. Retry after e-invoice details are available, or run PDF-only for this period.", userAction: { @@ -59,3 +65,46 @@ export function detectPostClickBlockedState( }, }; } + +// Captured live on 2026-09-10. The summary page renders an error panel naming the system's own +// reasons: no records for the period, the previous period's GSTR-3B not filed by the generation +// date, or a QRMP taxpayer outside a quarter-ending month. Each means there is no GSTR-2B to +// download for this period, which is a state of the return rather than a fault in reaching it. +// +// Matched on the portal's statement, not on its list of causes, because the causes are advisory +// text that can be reworded independently of the outcome. +export function isGstr2bNotGeneratedText(pageText: string): boolean { + return /\bgstr[\s-]?2b\s+could\s+not\s+be\s+generated\b/i.test(pageText); +} + +/** + * One wording, used by the step that observes the refusal and by the record it becomes. + * + * These were two strings saying the same thing differently -- the kind of duplicate nothing in + * this repo can contradict, because no test compares a transient message with the durable one + * that replaces it. + */ +export const GSTR2B_NOT_GENERATED_SAFE_MESSAGE = + "The GST Portal reported that it did not generate the auto-drafted GSTR-2B statement for this period, so there is nothing for Pack to download. Pack recorded the period as unavailable rather than retrying."; + +function detectGstr2bNotGenerated( + normalised: string, + target: FiledReturnsDownloadTarget, + safeSignals: string[], +): PortalDownloadTriggerResult | null { + if (!isGstr2bNotGeneratedText(normalised)) return null; + + return { + connectorId: "gst", + scopeId: filedReturnScopeId(target.returnType), + state: "blocked", + safeSignals: withSignal(safeSignals, "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, + }, + }; +} diff --git a/src/connectors/gst/filed-returns-summary-sheet.ts b/src/connectors/gst/filed-returns-summary-sheet.ts index 32f22d7f..f793abc2 100644 --- a/src/connectors/gst/filed-returns-summary-sheet.ts +++ b/src/connectors/gst/filed-returns-summary-sheet.ts @@ -1,3 +1,4 @@ +import type { FiledReturnsFullFiscalYearTargetStatus } from "./filed-returns-contracts"; import { CsvSizeLimitError, csvEmptyString, @@ -46,7 +47,28 @@ export const FILED_RETURNS_SUMMARY_HEADERS = [ "value_number", ] as const; -export type FiledReturnsSummaryOutcomeCategory = "staged" | "not-filed" | "artifact-unavailable"; +export type FiledReturnsSummaryOutcomeCategory = + "staged" | "not-filed" | "not-generated" | "artifact-unavailable"; + +/** + * What the ZIP should say about one artifact of one period. + * + * Both fiscal-year planners asked this in the same four branches. Only `staged` promises a file, + * so every other outcome has to be named rather than defaulted -- a period the portal declined to + * draft, defaulted to `staged`, becomes a file the run then reports itself as having failed to + * produce, and blocks the ZIP for a whole year on it. + */ +export function filedReturnsSummaryOutcomeCategory( + targetStatus: FiledReturnsFullFiscalYearTargetStatus, + safeSignals: ReadonlySet, + artifactType: FiledReturnsConcreteArtifactType, +): FiledReturnsSummaryOutcomeCategory { + if (targetStatus === "not-filed") return "not-filed"; + if (targetStatus === "not-generated") return "not-generated"; + return safeSignals.has(`filed-return-artifact-unavailable:${artifactType}`) + ? "artifact-unavailable" + : "staged"; +} export interface FiledReturnsSummaryPlanEntry { artifactType: FiledReturnsConcreteArtifactType; diff --git a/src/connectors/gst/offscreen-blob-url.ts b/src/connectors/gst/offscreen-blob-url.ts index 22c378f1..7fe3adff 100644 --- a/src/connectors/gst/offscreen-blob-url.ts +++ b/src/connectors/gst/offscreen-blob-url.ts @@ -365,7 +365,12 @@ function isFiledReturnsSummaryPlanShape(input: unknown): input is FiledReturnsSu function isSummaryOutcomeCategory( value: unknown, ): value is FiledReturnsSummaryPlanEntry["outcomeCategory"] { - return value === "staged" || value === "not-filed" || value === "artifact-unavailable"; + return ( + value === "staged" || + value === "not-filed" || + value === "not-generated" || + value === "artifact-unavailable" + ); } function isRecord(value: unknown): value is Record { diff --git a/src/entrypoints/popup/target-evidence.tsx b/src/entrypoints/popup/target-evidence.tsx index a3839a21..f9155ddb 100644 --- a/src/entrypoints/popup/target-evidence.tsx +++ b/src/entrypoints/popup/target-evidence.tsx @@ -29,6 +29,9 @@ const OUTCOME_LABELS: Readonly> = { "partly-saved": "Partly saved", captured: "Captured", "not-filed": "Not filed", + // Not "Not filed": an auto-drafted statement is never filed by the taxpayer, and saying so + // would put a claim about them on screen that the portal never made. + "not-generated": "Not generated", "needs-review": "Needs review", running: "In progress", pending: "Waiting", @@ -46,6 +49,7 @@ const OUTCOME_GLYPHS: Readonly> = { // confirmed. The difference is the whole point of the column. captured: "•", "not-filed": "–", + "not-generated": "–", "needs-review": "!", running: "…", pending: "·", From e6b62cb4fa5171b4b5b8b1884691898cd1db69b6 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:23:06 +0530 Subject: [PATCH 2/7] test(gst): pin the round trip of a period the portal declined to generate The suite was green while a saved plan could not be read back, so the missing test is the round trip itself: persist a ledger holding the new status, then read it. A guard that only checked a return value would have passed. --- ...eturns-single-period-bundle-ledger.test.ts | 27 ++++ .../full-fiscal-year-ledger.test.ts | 76 +++++++++++ ...scal-year-not-generated-round-trip.test.ts | 123 ++++++++++++++++++ ...d-returns-post-click-blocked-state.test.ts | 80 ++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 tests/background/full-fiscal-year-not-generated-round-trip.test.ts create mode 100644 tests/connectors/filed-returns-post-click-blocked-state.test.ts diff --git a/tests/background/filed-returns-single-period-bundle-ledger.test.ts b/tests/background/filed-returns-single-period-bundle-ledger.test.ts index cd6de41c..92cd24cd 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -373,6 +373,33 @@ describe("single-period bundle ledger", () => { ); }); + // The portal stating it did not generate a GSTR-2B is an answer about the period, not a fault in + // reaching it. Recording it as an absence is what lets the bundle finish and the fiscal-year run + // carry on; without this the run stops on a period that can never produce an artifact. + it("records a GSTR-2B the portal declined to generate as an absence", () => { + const initial = requiredLedger(); + const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; + + const updated = markSinglePeriodBundleArtifactUnavailable( + running, + "PDF", + { + connectorId: "gst", + safeMessage: "The GST Portal reported that it did not generate a GSTR-2B for this period.", + safeSignals: ["filed-gstr2b-not-generated"], + scopeId: "gst-filed-returns-gstr1-pdf-private-v0", + state: "blocked", + }, + PDF_STAGED_AT, + ); + + expect(updated).not.toBeNull(); + expect(updated!.artifacts.find((artifact) => artifact.artifactType === "PDF")).toMatchObject({ + missingReason: "artifact-filed-gstr2b-not-generated", + status: "unavailable", + }); + }); + it("rejects non-enumerated artifact signals before they can enter durable state", () => { const initial = requiredLedger(); const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; diff --git a/tests/background/full-fiscal-year-ledger.test.ts b/tests/background/full-fiscal-year-ledger.test.ts index e154030a..460a5b66 100644 --- a/tests/background/full-fiscal-year-ledger.test.ts +++ b/tests/background/full-fiscal-year-ledger.test.ts @@ -34,6 +34,9 @@ import { requireFullFiscalYearArtifactsStaged, scopeForFullFiscalYearTarget, } from "../../src/background/filed-returns-full-fiscal-year-staging"; +import { isResolvedFullFiscalYearTargetStatus } from "../../src/connectors/gst/filed-returns-contracts"; +import { FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES } from "../../src/connectors/gst/filed-returns-contracts"; +import { isFiledReturnsFullFiscalYearTargetStatus } from "../../src/connectors/gst/filed-returns-contracts"; describe("full fiscal year ledger", () => { it("requires the canonical GSTR-2B all-formats artifact set before staging succeeds", () => { @@ -1424,3 +1427,76 @@ function diagnosticStep( downloadDiagnostic, }; } + +describe("a period the portal never drafted", () => { + // GSTR-2B is auto-drafted, so a period with no statement is not the taxpayer failing to submit + // one. It gets its own status: mapping it to `not-filed` would print a claim about them that + // the portal never made, and mapping it to `blocked` stops a fiscal-year run on a period that + // no re-run can change. + it("maps the portal's refusal to its own status", () => { + expect( + targetStatusFromFlowStep({ + connectorId: "gst", + scopeId: "gst-filed-returns-gstr2b-private-v0", + state: "blocked", + safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeMessage: "The GST Portal reported that it did not generate a GSTR-2B for this period.", + }), + ).toBe("not-generated"); + }); + + it("does not report it as a return the taxpayer did not submit", () => { + expect( + targetStatusFromFlowStep({ + connectorId: "gst", + scopeId: "gst-filed-returns-gstr2b-private-v0", + state: "blocked", + safeSignals: ["filed-gstr2b-not-generated"], + safeMessage: "…", + }), + ).not.toBe("not-filed"); + }); + + it("lets the fiscal-year run carry on past it", () => { + expect(isResolvedFullFiscalYearTargetStatus("not-generated")).toBe(true); + expect(isResolvedFullFiscalYearTargetStatus("downloaded")).toBe(true); + expect(isResolvedFullFiscalYearTargetStatus("not-filed")).toBe(true); + // Everything a re-run might still change must keep stopping the run. + expect(isResolvedFullFiscalYearTargetStatus("blocked")).toBe(false); + expect(isResolvedFullFiscalYearTargetStatus("failed")).toBe(false); + expect(isResolvedFullFiscalYearTargetStatus("download-unconfirmed")).toBe(false); + }); +}); + +describe("runtime status allowlists stay exhaustive", () => { + // A `Set` literal does not have to list every member, so + // a status can be added to the union, pass type-checking everywhere, and still be rejected by a + // hand-kept runtime allowlist. That is what happened: a persisted run summary carrying a new + // status failed to parse, the start handler threw, and the run vanished from the panel. + it("parses a persisted summary for every status the union allows", () => { + for (const status of FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES) { + const summary = { + version: 1 as const, + financialYear: "2025-26", + returnType: "GSTR-2B" as const, + targets: [ + { + targetId: "t1", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-2B" as const, + status, + }, + ], + }; + expect( + JSON.stringify(summary).includes(status), + `${status} must be representable in a persisted summary`, + ).toBe(true); + expect( + isFiledReturnsFullFiscalYearTargetStatus(status), + `${status} must survive durable parsing`, + ).toBe(true); + } + }); +}); diff --git a/tests/background/full-fiscal-year-not-generated-round-trip.test.ts b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts new file mode 100644 index 00000000..94e11a36 --- /dev/null +++ b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const stored = vi.hoisted(() => ({ current: {} as Record })); + +const browserMocks = vi.hoisted(() => ({ + storage: { + local: { + get: vi.fn(async (key?: unknown) => { + if (typeof key === "string") return { [key]: stored.current[key] }; + return stored.current; + }), + remove: vi.fn(async (keys: string | string[]) => { + for (const key of Array.isArray(keys) ? keys : [keys]) delete stored.current[key]; + }), + set: vi.fn(async (values: Record) => { + Object.assign(stored.current, values); + }), + }, + }, +})); + +vi.mock("wxt/browser", () => ({ browser: browserMocks })); + +import { + persistLedger, + readPlanLedgersStorageState, +} from "../../src/background/filed-returns-full-fiscal-year-run-state"; +import { + createFullFiscalYearLedger, + markFullFiscalYearTargetRunning, + markFullFiscalYearTargetTerminal, +} from "../../src/background/filed-returns-full-fiscal-year-ledger"; +import { exportFullFiscalYearZip } from "../../src/background/filed-returns-full-fiscal-year-zip"; +import { GSTR2B_NOT_GENERATED_SAFE_MESSAGE } from "../../src/connectors/gst/filed-returns-post-click-blocked-state"; +import type { FiledReturnsDownloadScope } from "../../src/connectors/gst/filed-returns-contracts"; + +const deps = { + storageKeys: { fullFiscalYearLedger: "legacy", fullFiscalYearLedgerIndex: "index" }, +}; + +const scope: FiledReturnsDownloadScope = { + financialYear: "2025-26", + period: "FULL_FISCAL_YEAR", + returnType: "GSTR-2B", + artifactType: "PDF_AND_EXCEL", +}; + +// The portal's own refusal, as captured live: it declines to draft GSTR-2B for a taxpayer with no +// inward supplies. That is a permanent monthly outcome for such a taxpayer, not a transient error. +function notGeneratedStep() { + return { + connectorId: "gst" as const, + scopeId: "gst-gstr2b-private-v0", + state: "blocked" as const, + safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeMessage: GSTR2B_NOT_GENERATED_SAFE_MESSAGE, + }; +} + +describe("a full-year run whose period the portal never generated", () => { + beforeEach(() => { + stored.current = {}; + vi.clearAllMocks(); + }); + + it("can still read back the plan it just saved", async () => { + const now = new Date("2026-09-10T00:00:00.000Z"); + let ledger = createFullFiscalYearLedger(scope, now, ["April", "May"]); + const april = ledger.targets[0]!.targetId; + + ledger = markFullFiscalYearTargetRunning(ledger, april, now); + await persistLedger(deps, ledger); + + ledger = markFullFiscalYearTargetTerminal( + ledger, + april, + "not-generated", + notGeneratedStep(), + now, + ); + expect(ledger.targets[0]!.status).toBe("not-generated"); + // A terminal state must say what actually happened. The generic review copy sent the user to + // browser Downloads to look for a file the portal had just said it never produced. + expect(ledger.targets[0]!.safeMessage).toContain("did not generate"); + expect(ledger.targets[0]!.safeMessage).not.toContain("Check Downloads"); + await persistLedger(deps, ledger); + + // The observable failure: the saved plan came back unreadable, so the very next persist threw + // out of the background message handler and the run's details vanished from the panel. + expect(await readPlanLedgersStorageState(deps)).toMatchObject({ state: "valid" }); + await expect(persistLedger(deps, ledger)).resolves.toBeUndefined(); + }); + + it("does not block its ZIP on an artifact the portal never produced", async () => { + const now = new Date("2026-09-10T00:00:00.000Z"); + let ledger = createFullFiscalYearLedger(scope, now, ["April"]); + const april = ledger.targets[0]!.targetId; + ledger = markFullFiscalYearTargetRunning(ledger, april, now); + ledger = markFullFiscalYearTargetTerminal( + ledger, + april, + "not-generated", + notGeneratedStep(), + now, + ); + + const completeStep = { + connectorId: "gst" as const, + scopeId: "gst-gstr2b-private-v0", + state: "downloaded" as const, + safeSignals: ["full-fiscal-year-complete"], + safeMessage: "Pack completed the local full fiscal year run.", + }; + + const step = await exportFullFiscalYearZip(ledger, completeStep); + + // Before this fix the planner counted the never-produced file as one it had failed to stage, + // so a taxpayer with no inward supplies could never finish a GSTR-2B year. + expect(step.safeSignals).not.toContain("full-fiscal-year-zip-artifact-staging-incomplete"); + expect(step.safeSignals).toContain("full-fiscal-year-no-zip-artifacts"); + expect(step.state).not.toBe("blocked"); + }); +}); diff --git a/tests/connectors/filed-returns-post-click-blocked-state.test.ts b/tests/connectors/filed-returns-post-click-blocked-state.test.ts new file mode 100644 index 00000000..122db83d --- /dev/null +++ b/tests/connectors/filed-returns-post-click-blocked-state.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { detectPostClickBlockedState } from "../../src/connectors/gst/filed-returns-post-click-blocked-state"; +import { createGstDocument } from "./filed-returns-flow.test-helpers"; + +describe("GSTR-2B the portal did not generate", () => { + // Captured live on 2026-09-10. The summary route renders this panel instead of a downloadable + // GSTR-2B. It is a state of the return, not a fault in reaching it, and retrying cannot change + // it within a run -- so it is recorded as an absence rather than left as a stall. + const errorPanel = ` +
+

GSTR-2B

+
+ 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. + 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 + iii. You are a QRMP taxpayer and current return period is not a quarter ending month +
+ +
+ `; + + const target = { + actionId: "test-action", + artifactType: "PDF" as const, + financialYear: "2025-26", + period: "April", + returnType: "GSTR-2B" as const, + }; + + it("recognises the portal's own statement that it did not generate one", () => { + const documentRef = createGstDocument( + errorPanel, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + const result = detectPostClickBlockedState(documentRef, target, ["gstr2b-summary-route"]); + + expect(result?.state).toBe("blocked"); + expect(result?.safeSignals).toContain("filed-gstr2b-not-generated"); + // The preceding signals are kept, not replaced. + expect(result?.safeSignals).toContain("gstr2b-summary-route"); + }); + + it("does not claim a refusal from a summary page that rendered normally", () => { + const documentRef = createGstDocument( + ` +
+

GSTR-2B

+ +
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])).toBeNull(); + }); + + // The numbered conditions are advisory text the portal can reword independently of the outcome, + // so matching them rather than the statement would break on a rewrite that changed nothing. + it("matches the outcome statement rather than the list of causes", () => { + const documentRef = createGstDocument( + ` +
+
+ Error! GSTR-2B could not be generated by the System for this return period. +
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])?.safeSignals).toContain( + "filed-gstr2b-not-generated", + ); + }); +}); From 1d682024ae9b3b126381ab25b5913ad38082c650 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 08:49:14 +0530 Subject: [PATCH 3/7] refactor(gst): derive the bundle ledger's declined-artifact structures The set of reasons and the map from flow signal to reason were two more copies of the pair the diagnostics module now owns. Both derive from it, so a third portal refusal is one line rather than five edits across three modules. --- ...filed-returns-single-period-bundle-ledger.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index 9d729925..3937636a 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -21,16 +21,17 @@ import { import { filedReturnScopeId } from "../connectors/gst/filed-returns-return-descriptors"; import { isValidFiledReturnsDownloadDiagnosticState } from "./filed-returns-download-diagnostic-state"; import { PACK_LOCAL_STORAGE_KEYS } from "./storage-keys"; -const MISSING_ARTIFACT_REASONS = new Set([ - "artifact-filed-gstr1-excel-no-details-available", - "artifact-filed-gstr2b-not-generated", -]); +import { + DECLINED_ARTIFACT_REASONS as DECLINED_ARTIFACT_REASONS_LIST, + DECLINED_ARTIFACT_SIGNALS, + declinedArtifactReason, +} from "../connectors/gst/filed-returns-acquisition-diagnostics"; +const MISSING_ARTIFACT_REASONS = new Set(DECLINED_ARTIFACT_REASONS_LIST); // The flow signal the portal's refusal carries, and the reason recorded against the artifact. -const DECLINED_ARTIFACT_REASONS = new Map([ - ["filed-gstr1-excel-no-details-available", "artifact-filed-gstr1-excel-no-details-available"], - ["filed-gstr2b-not-generated", "artifact-filed-gstr2b-not-generated"], -]); +const DECLINED_ARTIFACT_REASONS = new Map( + DECLINED_ARTIFACT_SIGNALS.map((signal) => [signal, declinedArtifactReason(signal)]), +); const LEDGER_KEYS = [ "artifactPlan", From 6314459dd8f0686a44f1c2f4e8eaf5b37fd3bac8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 08:56:23 +0530 Subject: [PATCH 4/7] fix(gst): stop a declined period being discarded as if it were nothing The guard deciding whether a cancelled run may be replaced without asking listed downloaded, manually-observed and not-filed. A declined period is the portal answering that there is nothing to download, exactly as an unfiled one is, so a run holding one was protected and a run holding the other was not. Two more copies of the resolved split turned up written as || chains rather than sets, which is why the earlier sweep missed them: one was the exact complement of resolved, the other that minus the two states a run reaches by itself. Both derive now, and the second is shared with the all-returns path that asked the same question. --- ...s-all-supported-full-fiscal-year-ledger.ts | 9 ++++---- ...iled-returns-full-fiscal-year-run-state.ts | 9 ++++++-- .../filed-returns-full-fiscal-year-summary.ts | 23 ++++++------------- src/connectors/gst/filed-returns-contracts.ts | 14 +++++++++++ 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts b/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts index c95baf2f..f8abd786 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-ledger.ts @@ -3,7 +3,10 @@ import type { FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; -import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; +import { + isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, +} from "../connectors/gst/filed-returns-contracts"; import { ALL_SUPPORTED_FULL_FISCAL_YEAR_CATALOGUE_VERSION, expandAllSupportedFullFiscalYearTargetPlan, @@ -64,9 +67,7 @@ function isExplicitlyRetryableTarget( target: FiledReturnsAllSupportedFullFiscalYearTarget, ): boolean { return ( - !isResolvedFullFiscalYearTargetStatus(target.status) && - target.status !== "pending" && - target.status !== "running" && + needsExplicitFullFiscalYearRetry(target.status) && !target.safeSignals.some((signal) => NON_RESUMABLE_EXPLICIT_RETRY_SIGNALS.has(signal)) ); } diff --git a/src/background/filed-returns-full-fiscal-year-run-state.ts b/src/background/filed-returns-full-fiscal-year-run-state.ts index a8d52a12..aaf6227a 100644 --- a/src/background/filed-returns-full-fiscal-year-run-state.ts +++ b/src/background/filed-returns-full-fiscal-year-run-state.ts @@ -6,6 +6,7 @@ import type { FiledReturnsFullFiscalYearLedger, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; import { isCleanedZipPhase } from "../connectors/gst/filed-returns-contracts"; import type { PackMessageResponse } from "../connectors/gst/messages"; import { filedReturnScopeId } from "../connectors/gst/filed-returns-return-descriptors"; @@ -32,9 +33,13 @@ import { } from "./filed-returns-full-fiscal-year-summary"; import { persistCanonicalFiledReturnsFlowSummary } from "./filed-returns-session-summary"; +// A target the run should not silently discard: the portal answered, or a person reported what they +// saw. Written as a list, this said `not-filed` but not `not-generated` -- two answers of the same +// kind, one of which would have let a cancelled run holding it be replaced without asking. export function hasTerminalPositiveTarget(ledger: FiledReturnsFullFiscalYearLedger): boolean { - return ledger.targets.some((target) => - ["downloaded", "manually-observed", "not-filed"].includes(target.status), + return ledger.targets.some( + (target) => + isResolvedFullFiscalYearTargetStatus(target.status) || target.status === "manually-observed", ); } diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index 044dfdaf..e4b293a8 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -9,6 +9,7 @@ import type { } from "../connectors/gst/filed-returns-contracts"; import { isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, isCleanedZipPhase, zipPhaseProvesDelivery, } from "../connectors/gst/filed-returns-contracts"; @@ -240,7 +241,7 @@ export function needsResumeConfirmation(ledger: FiledReturnsFullFiscalYearLedger * ZIP afterwards. So `downloaded` means "Pack holds these bytes" until the ZIP * delivery signal appears, and only then does it mean the browser has them. */ -function targetOutcome( +export function filedReturnsTargetOutcome( status: FiledReturnsFullFiscalYearTargetStatus, zipDelivered: boolean, runInterrupted: boolean, @@ -345,7 +346,7 @@ export function fullFiscalYearTargetEvidence( RUN_INDETERMINATE_SIGNALS.some((signal) => flowStep.safeSignals.includes(signal)); return ledger.targets.map((target) => ({ period: target.period, - outcome: targetOutcome( + outcome: filedReturnsTargetOutcome( target.status, zipDelivered, runInterrupted, @@ -496,24 +497,14 @@ function fullFiscalYearRecoveryTarget( : ledger.targets.find(isRecoverableFullFiscalYearTarget); } +// The exact complement of resolved, so it is derived rather than restated. Written out, this was a +// seven-line list that had to be edited every time the union grew. function isRecoverableFullFiscalYearTarget(target: FiledReturnsFullFiscalYearTarget): boolean { - return ( - target.status === "pending" || - target.status === "download-unconfirmed" || - target.status === "running" || - target.status === "blocked" || - target.status === "failed" || - target.status === "cancelled" || - target.status === "manually-observed" - ); + return !isResolvedFullFiscalYearTargetStatus(target.status); } function hasRecoverableActionRequiredTarget(ledger: FiledReturnsFullFiscalYearLedger): boolean { - return ledger.targets.some((target) => - ["blocked", "failed", "cancelled", "download-unconfirmed", "manually-observed"].includes( - target.status, - ), - ); + return ledger.targets.some((target) => needsExplicitFullFiscalYearRetry(target.status)); } export function activeFullFiscalYearStep( diff --git a/src/connectors/gst/filed-returns-contracts.ts b/src/connectors/gst/filed-returns-contracts.ts index 8ba7a213..7b33ffc8 100644 --- a/src/connectors/gst/filed-returns-contracts.ts +++ b/src/connectors/gst/filed-returns-contracts.ts @@ -274,6 +274,20 @@ export function isResolvedFullFiscalYearTargetStatus( return RESOLVED_TARGET_STATUSES.has(status); } +/** + * Unresolved, and not a state the run reaches by itself. What is left needs the user to choose. + * + * Derived rather than listed: a status that is neither resolved nor pending/running belongs here by + * definition, so a new one cannot land in neither bucket. + */ +export function needsExplicitFullFiscalYearRetry( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + return ( + !isResolvedFullFiscalYearTargetStatus(status) && status !== "pending" && status !== "running" + ); +} + export interface FiledReturnsFullFiscalYearTarget { targetId: string; financialYear: string; From ba244317f33d06dc4fa5fd954141c8e6f4e2ef40 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 08:56:23 +0530 Subject: [PATCH 5/7] test(gst): pin that a declined period is work worth keeping --- ...scal-year-not-generated-round-trip.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/background/full-fiscal-year-not-generated-round-trip.test.ts b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts index 94e11a36..77a73c37 100644 --- a/tests/background/full-fiscal-year-not-generated-round-trip.test.ts +++ b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts @@ -22,6 +22,7 @@ const browserMocks = vi.hoisted(() => ({ vi.mock("wxt/browser", () => ({ browser: browserMocks })); import { + hasTerminalPositiveTarget, persistLedger, readPlanLedgersStorageState, } from "../../src/background/filed-returns-full-fiscal-year-run-state"; @@ -121,3 +122,24 @@ describe("a full-year run whose period the portal never generated", () => { expect(step.state).not.toBe("blocked"); }); }); + +describe("a cancelled run that recorded a declined period", () => { + // The guard that decides whether a cancelled run may be silently replaced listed `not-filed` but + // not `not-generated`. Both are the portal answering that there is nothing to download, so a run + // holding one was protected and a run holding the other was not. + it("counts as work the run must not discard without asking", () => { + const now = new Date("2026-09-11T00:00:00.000Z"); + let ledger = createFullFiscalYearLedger(scope, now, ["April", "May"]); + const april = ledger.targets[0]!.targetId; + ledger = markFullFiscalYearTargetRunning(ledger, april, now); + ledger = markFullFiscalYearTargetTerminal( + ledger, + april, + "not-generated", + notGeneratedStep(), + now, + ); + + expect(hasTerminalPositiveTarget(ledger)).toBe(true); + }); +}); From ce75e25c67e01ee2ba056e3f10f3628bb235170a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:52:29 +0530 Subject: [PATCH 6/7] fix(gst): bind the post-click refusal to the period on screen `detectPostClickBlockedState` resolved the GSTR-2B refusal from the target's return type plus whatever text was on the body. No route, year, period or action identity took part, and the signal it emits is terminal. The summary route does not change per period, and the panel outlives the period it was loaded for, so one period's refusal would answer for every later period a run asks about -- which is exactly how the observation path next door recorded eleven months a live run never navigated to. A target is a scope with an action id, so the guard that path already uses applies here unchanged: no new recogniser, no second notion of what counts as the visible period. Removing a duplicate on the way: `normalisedPageText` was a second copy of `normaliseText` that skipped the lower-casing, so the label patterns could not have matched against it. Both readers now take the same reading of the page. --- .../filed-returns-post-click-blocked-state.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 761bdcf9..49524e55 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -1,6 +1,8 @@ import type { PortalDownloadTriggerResult } from "../../core/contracts"; import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts"; +import { normaliseText } from "./filed-returns-dom"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { readDocumentText, verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; // The portal declining to produce an artifact, in its own words. // @@ -14,11 +16,6 @@ import { filedReturnScopeId } from "./filed-returns-return-descriptors"; // records a download: every result here is `blocked`, and completion still requires correlated // download evidence. -function normalisedPageText(documentRef: Document): string { - const text = documentRef.body?.innerText ?? documentRef.body?.textContent ?? ""; - return text.replace(/\s+/g, " ").trim(); -} - function withSignal(safeSignals: string[], signal: string): string[] { return safeSignals.includes(signal) ? [...safeSignals] : [...safeSignals, signal]; } @@ -28,12 +25,14 @@ export function detectPostClickBlockedState( target: FiledReturnsDownloadTarget, safeSignals: string[], ): PortalDownloadTriggerResult | null { - const normalised = normalisedPageText(documentRef); + // The same reading the observation path uses. This was a second, subtly different copy: it + // skipped the lower-casing, so a case-sensitive label pattern could not have matched against it. + const normalised = normaliseText(readDocumentText(documentRef)); if (target.returnType === "GSTR-1" && target.artifactType === "EXCEL") { return detectGstr1ExcelNoDetails(normalised, target, safeSignals); } if (target.returnType === "GSTR-2B") { - return detectGstr2bNotGenerated(normalised, target, safeSignals); + return detectGstr2bNotGenerated(documentRef, normalised, target, safeSignals); } return null; } @@ -88,12 +87,23 @@ export const GSTR2B_NOT_GENERATED_SAFE_MESSAGE = "The GST Portal reported that it did not generate the auto-drafted GSTR-2B statement for this period, so there is nothing for Pack to download. Pack recorded the period as unavailable rather than retrying."; function detectGstr2bNotGenerated( + documentRef: Document, normalised: string, target: FiledReturnsDownloadTarget, safeSignals: string[], ): PortalDownloadTriggerResult | null { if (!isGstr2bNotGeneratedText(normalised)) return null; + // The refusal panel is not bound to the target by the fact that it is on screen. The summary + // route does not change per period and keeps rendering the panel -- and the header naming the + // period it belongs to -- until a new search settles, so a stale panel will answer for whichever + // target asks. Recording it resolves that target outright, with no artifact to corroborate it + // afterwards, which makes the visible header the whole of the evidence. + // + // 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; + return { connectorId: "gst", scopeId: filedReturnScopeId(target.returnType), From 84f91b2ccca9cc27646cf0b017dc5caaea105c10 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 14:52:29 +0530 Subject: [PATCH 7/7] test(gst): pin that a stale refusal cannot answer for another period The two existing fixtures carried no header block, which the captured live panel does -- being the reason the panel can outlive its period at all. Bringing them to the captured shape is what makes the binding testable. Both new cases fail with the guard removed. --- ...d-returns-post-click-blocked-state.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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 122db83d..7846ee3d 100644 --- a/tests/connectors/filed-returns-post-click-blocked-state.test.ts +++ b/tests/connectors/filed-returns-post-click-blocked-state.test.ts @@ -8,7 +8,10 @@ describe("GSTR-2B the portal did not generate", () => { // it within a run -- so it is recorded as an absence rather than left as a stall. const errorPanel = `
-

GSTR-2B

+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+

Financial Year - 2025-26

+

Return Period - April

+

Generation date -

Error! GSTR-2B could not be generated by the System. Kindly compute your GSTR 2B manually by @@ -65,6 +68,8 @@ describe("GSTR-2B the portal did not generate", () => { const documentRef = createGstDocument( `
+

Financial Year - 2025-26

+

Return Period - April

Error! GSTR-2B could not be generated by the System for this return period.
@@ -77,4 +82,33 @@ describe("GSTR-2B the portal did not generate", () => { "filed-gstr2b-not-generated", ); }); + + // The summary route does not change per period, and the panel outlives the period it was loaded + // for. Resolving a target from the return type plus whatever text is on the body lets one + // period's refusal answer for every later period a run asks about -- which is how a live run + // recorded eleven months it never navigated to, on the observation path next door. + it("refuses to answer for a period the visible refusal is not about", () => { + const documentRef = createGstDocument( + errorPanel, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + const result = detectPostClickBlockedState(documentRef, { ...target, period: "May" }, []); + + expect(result).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( + ` +
+
GSTR-2B could not be generated by the System.
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])).toBeNull(); + }); });