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..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,6 +3,10 @@ import type { FiledReturnsFullFiscalYearTargetStatus, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; +import { + isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, +} from "../connectors/gst/filed-returns-contracts"; import { ALL_SUPPORTED_FULL_FISCAL_YEAR_CATALOGUE_VERSION, expandAllSupportedFullFiscalYearTargetPlan, @@ -29,17 +33,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 +57,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 +67,7 @@ function isExplicitlyRetryableTarget( target: FiledReturnsAllSupportedFullFiscalYearTarget, ): boolean { return ( - EXPLICIT_RETRY_TARGET_STATUSES.has(target.status) && + needsExplicitFullFiscalYearRetry(target.status) && !target.safeSignals.some((signal) => NON_RESUMABLE_EXPLICIT_RETRY_SIGNALS.has(signal)) ); } @@ -118,8 +111,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 +329,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 +400,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 +418,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 +477,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-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 eae18533..e4b293a8 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -8,6 +8,8 @@ import type { PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; import { + isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, isCleanedZipPhase, zipPhaseProvesDelivery, } from "../connectors/gst/filed-returns-contracts"; @@ -121,11 +123,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 +138,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 +161,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"; } @@ -238,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, @@ -343,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, @@ -388,7 +391,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, @@ -494,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/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..3937636a 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -21,7 +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"]); +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( + DECLINED_ARTIFACT_SIGNALS.map((signal) => [signal, declinedArtifactReason(signal)]), +); const LEDGER_KEYS = [ "artifactPlan", @@ -854,12 +864,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..7b33ffc8 100644 --- a/src/connectors/gst/filed-returns-contracts.ts +++ b/src/connectors/gst/filed-returns-contracts.ts @@ -227,16 +227,66 @@ 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); +} + +/** + * 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; @@ -374,6 +424,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 95d4295d..da1521c0 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -1,38 +1,49 @@ import type { PortalDownloadTriggerResult } from "../../core/contracts"; -import { delay } from "../../core/time"; import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts"; +import { normaliseText } from "./filed-returns-dom"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; +import { readDocumentText, verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; -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( +function withSignal(safeSignals: string[], signal: string): string[] { + return safeSignals.includes(signal) ? [...safeSignals] : [...safeSignals, signal]; +} + +export function detectPostClickBlockedState( 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); - +): PortalDownloadTriggerResult | null { + // 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(documentRef, normalised, target, safeSignals); + } + if (target.returnType === "GSTR-2B") { + return detectGstr2bNotGenerated(documentRef, normalised, target, safeSignals); + } return null; } -export function detectPostClickBlockedState( +function detectGstr1ExcelNoDetails( documentRef: Document, + normalised: string, target: FiledReturnsDownloadTarget, safeSignals: string[], ): PortalDownloadTriggerResult | null { - if (target.returnType !== "GSTR-1" || target.artifactType !== "EXCEL") return null; - - const text = documentRef.body?.innerText ?? documentRef.body?.textContent ?? ""; - const normalised = text.replace(/\s+/g, " ").trim(); if ( !/\bno\s+details\s+available\s+for\s+download\b/i.test(normalised) || !/\be-?invoices?\b/i.test(normalised) @@ -55,12 +66,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: { @@ -71,3 +77,57 @@ 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( + 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), + 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: "·", 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..77a73c37 --- /dev/null +++ b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts @@ -0,0 +1,145 @@ +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 { + hasTerminalPositiveTarget, + 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"); + }); +}); + +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); + }); +}); 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..7846ee3d --- /dev/null +++ b/tests/connectors/filed-returns-post-click-blocked-state.test.ts @@ -0,0 +1,114 @@ +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- 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 + 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( + ` +
+

Financial Year - 2025-26

+

Return Period - April

+
+ 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", + ); + }); + + // 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(); + }); +});