From 2c7d3035f4170b542a412ec60e94aa8a9e156224 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 16:32:25 +0530 Subject: [PATCH 01/48] fix(gst): bind refusals and retain uncertain acquisition state --- .../background-failure-fingerprint.ts | 42 ++++ ...s-all-supported-full-fiscal-year-ledger.ts | 37 ++-- ...-all-supported-full-fiscal-year-summary.ts | 33 ++- ...l-supported-full-fiscal-year-validation.ts | 37 ++-- ...urns-all-supported-full-fiscal-year-zip.ts | 12 +- ...-returns-all-supported-full-fiscal-year.ts | 41 ++-- src/background/filed-returns-current-state.ts | 16 +- .../filed-returns-download-trigger.ts | 151 +++++++++++--- .../filed-returns-durable-summary.ts | 24 +-- .../filed-returns-full-fiscal-year-ledger.ts | 18 +- ...filed-returns-full-fiscal-year-recovery.ts | 13 +- ...iled-returns-full-fiscal-year-run-state.ts | 12 +- .../filed-returns-full-fiscal-year-staging.ts | 3 +- .../filed-returns-full-fiscal-year-summary.ts | 55 ++--- ...led-returns-full-fiscal-year-validation.ts | 39 ++-- .../filed-returns-full-fiscal-year-zip.ts | 12 +- .../filed-returns-full-fiscal-year.ts | 11 +- ...led-returns-single-period-bundle-ledger.ts | 32 ++- .../filed-returns-single-period-flow.ts | 3 +- .../filed-returns-single-period-summary.ts | 10 +- src/background/gstr2b-artifact-acquisition.ts | 5 +- src/background/local-data.ts | 19 +- src/connectors/gst/artifact-source.ts | 53 +++-- src/connectors/gst/artifact-validation.ts | 58 +++++- .../filed-returns-acquisition-diagnostics.ts | 74 +++++++ src/connectors/gst/filed-returns-contracts.ts | 165 ++++++++++++++- .../gst/filed-returns-declined-artifact.ts | 195 ++++++++++++++++++ .../gst/filed-returns-detail-navigation.ts | 9 + .../gst/filed-returns-durable-signals.ts | 65 +++++- .../gst/filed-returns-durable-status.ts | 12 ++ .../gst/filed-returns-observer-signals.ts | 14 +- .../filed-returns-post-click-blocked-state.ts | 101 +++++---- .../gst/filed-returns-return-descriptors.ts | 6 +- .../gst/filed-returns-summary-sheet.ts | 24 ++- src/connectors/gst/gstr2b-flow.ts | 80 ++++++- src/connectors/gst/gstr2b-summary.ts | 28 ++- src/connectors/gst/offscreen-blob-url.ts | 7 +- .../gst/portal-artifact-endpoints.ts | 58 +++++- src/entrypoints/background.ts | 17 +- src/entrypoints/panel/panel-surface.tsx | 4 +- src/entrypoints/popup/target-evidence.tsx | 4 + 41 files changed, 1245 insertions(+), 354 deletions(-) create mode 100644 src/background/background-failure-fingerprint.ts create mode 100644 src/connectors/gst/filed-returns-acquisition-diagnostics.ts create mode 100644 src/connectors/gst/filed-returns-declined-artifact.ts diff --git a/src/background/background-failure-fingerprint.ts b/src/background/background-failure-fingerprint.ts new file mode 100644 index 00000000..e00ecddf --- /dev/null +++ b/src/background/background-failure-fingerprint.ts @@ -0,0 +1,42 @@ +// Enough to name a thrown failure, and nothing that could carry portal text. +// +// An error's message is not safe to render: it can quote a page, a URL, or a field value. Its +// class name and the innermost stack symbol can name where a failure happened without repeating +// anything the portal said -- but only if each is established to be this bundle's, rather than +// merely made to look harmless. +// +// Stripping punctuation was the earlier approach and it was worse than no filter: a frame pointing +// at a portal URL came back with its slashes deleted, so the value *looked* like a symbol +// precisely because the characters that would have exposed it were gone. A filter that launders +// its input is not a guard. +// +// This keeps durable failure fingerprints useful without retaining untrusted error text. + +// A frame names a place in this bundle only when the file it points at is this bundle's. The +// symbol is what is kept; the URL is what proves the symbol is ours, and it is never kept. An +// anonymous frame has no symbol to take, and a frame from anywhere else does not match at all. +const BUNDLE_FRAME = /^\s*at\s+(?:async\s+)?([A-Za-z_$][\w$.]{0,59})\s+\(chrome-extension:\/\//u; + +// Every error class in this bundle ends in `Error`, as do the platform's own; `DOMException` is +// the one exception the platform makes. Letters and that suffix cannot spell a GSTIN, an ARN, or +// a URL, and a name that fails the shape degrades to `Error` rather than being laundered into one. +const BUNDLE_ERROR_NAME = /^[A-Za-z]{1,40}Error$/u; + +function safeErrorName(name: string): string { + if (name === "DOMException") return name; + return BUNDLE_ERROR_NAME.test(name) ? name : "Error"; +} + +export function backgroundFailureFingerprint(error: unknown): string { + if (!(error instanceof Error)) return "NonError"; + const name = safeErrorName(error.name || "Error"); + const symbol = + typeof error.stack === "string" + ? (error.stack + .split("\n") + .slice(1) + .map((line) => BUNDLE_FRAME.exec(line)?.[1]) + .find(Boolean) ?? "") + : ""; + return symbol ? `${name} at ${symbol}` : name; +} 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..4165624d 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts @@ -1,10 +1,11 @@ +import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; 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 +23,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 +125,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, @@ -280,16 +276,15 @@ function targetOutcome( target: FiledReturnsAllSupportedFullFiscalYearTarget, zipDelivered: boolean, ): FiledReturnsAllSupportedFullFiscalYearTargetEvidence["outcome"] { - if (target.status === "not-filed") return "not-filed"; - if (target.status === "downloaded") { - if (!zipDelivered) return "captured"; - return target.safeSignals.some((signal) => - signal.startsWith("filed-return-artifact-unavailable:"), - ) - ? "partly-saved" - : "saved"; - } - if (target.status === "pending") return "pending"; - if (target.status === "running") return "running"; - return "needs-review"; + // The same exhaustive mapping the single-return fiscal-year path uses. Two hand-written copies + // stood here, each ending in a `needs-review` default that silently absorbed any status they had + // not been told about -- so a period the portal declined to generate was reported to the user as + // needing review, in the one run type where it could not be. The shared record fails to compile + // instead, which is the only reason the single-return path was already right. + return filedReturnsTargetOutcome( + target.status, + zipDelivered, + false, + target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")), + ); } diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts b/src/background/filed-returns-all-supported-full-fiscal-year-validation.ts index ba98673f..d3275e48 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, @@ -39,6 +43,7 @@ import { canonicalFullFiscalYearPlanPeriods, isCanonicalFullFiscalYearPeriodPlan, } from "./filed-returns-full-fiscal-year-validation"; +import { filedReturnsTargetStatusBehaviour } from "../connectors/gst/filed-returns-contracts"; export const ALL_SUPPORTED_FULL_FISCAL_YEAR_PLAN_VERSION = "all-supported-filed-returns-targets-v1" as const; @@ -119,21 +124,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 +271,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 +481,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) || @@ -531,10 +523,13 @@ function isTarget( ) { return false; } - return ( - verifiedTarget.status !== "not-filed" || - verifiedTarget.safeSignals.includes("filed-return-positively-not-filed") - ); + // The evidence a claim needs is a property of the status, not a rule each validator remembers. + // Spelled out, only `not-filed` was checked here and in the single-return validator, so a stored + // record could assert `not-generated` with nothing behind it -- in both. + const requiredEvidenceSignal = filedReturnsTargetStatusBehaviour( + verifiedTarget.status, + ).requiredEvidenceSignal; + return !requiredEvidenceSignal || verifiedTarget.safeSignals.includes(requiredEvidenceSignal); } function hasCanonicalConcreteArtifacts( 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..bb07e8de 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year.ts @@ -1,10 +1,11 @@ +import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; 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 +62,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 +374,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); @@ -552,7 +550,7 @@ async function runAllSupportedFullFiscalYearTargets( systemErrorPredecessor, ), ); - const targetStatus = targetStatusFromFlowStep(flowStep); + const targetStatus = targetStatusFromFlowStep(flowStep, scope.returnType); ledger = markAllSupportedFullFiscalYearTargetTerminal( ledger, nextTarget.targetId, @@ -568,7 +566,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 +715,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, @@ -748,18 +746,17 @@ function targetOutcome( target: FiledReturnsAllSupportedFullFiscalYearTarget, zipDelivered: boolean, ): FiledReturnsAllSupportedFullFiscalYearFlowSummary["targetEvidence"][number]["outcome"] { - if (target.status === "not-filed") return "not-filed"; - if (target.status === "downloaded") { - if (!zipDelivered) return "captured"; - return target.safeSignals.some((signal) => - signal.startsWith("filed-return-artifact-unavailable:"), - ) - ? "partly-saved" - : "saved"; - } - if (target.status === "pending") return "pending"; - if (target.status === "running") return "running"; - return "needs-review"; + // The same exhaustive mapping the single-return fiscal-year path uses. Two hand-written copies + // stood here, each ending in a `needs-review` default that silently absorbed any status they had + // not been told about -- so a period the portal declined to generate was reported to the user as + // needing review, in the one run type where it could not be. The shared record fails to compile + // instead, which is the only reason the single-return path was already right. + return filedReturnsTargetOutcome( + target.status, + zipDelivered, + false, + target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")), + ); } function scopeForTarget( diff --git a/src/background/filed-returns-current-state.ts b/src/background/filed-returns-current-state.ts index 55ed1f3e..aa513a03 100644 --- a/src/background/filed-returns-current-state.ts +++ b/src/background/filed-returns-current-state.ts @@ -18,6 +18,7 @@ import { readRetainedPlanLedgers, } from "./filed-returns-full-fiscal-year-run-state"; import { readCurrentFiledReturnsTargetReviewSummary } from "./filed-returns-target-review"; +import { isResolvedFullFiscalYearTargetStatus } from "../connectors/gst/filed-returns-contracts"; export interface FiledReturnsCurrentStateDeps { storageKeys: { @@ -143,17 +144,10 @@ function isRetainedZipRetrySummary( function isActionableFullFiscalYearLedger(ledger: FiledReturnsFullFiscalYearLedger): boolean { if (ledger.status === "complete") return false; - return ledger.targets.some((target) => - [ - "pending", - "running", - "download-unconfirmed", - "blocked", - "failed", - "cancelled", - "manually-observed", - ].includes(target.status), - ); + // The complement of "answered", asked as such. This was a hand-written list of seven members, + // and `local-data.ts` kept a second copy of the same idea that had six -- so a cancelled target + // made a ledger actionable here while leaving it clearable there. + return ledger.targets.some((target) => !isResolvedFullFiscalYearTargetStatus(target.status)); } function isNewerSinglePeriodSummary( diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index a13862ba..be5c2b7e 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -40,9 +40,15 @@ import { gstr3bFullFiscalYearAcquisitionNotWiredStep, isGstr3bFullFiscalYearAcquisitionScope, } from "./gstr3b-artifact-acquisition-block"; +import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; +import { persistSinglePeriodSummary } from "./filed-returns-single-period-summary"; type FlowStepResponse = Extract; +// Only the portal's own declined-artifact answers are adopted from a post-click inspection; any +// other page state leaves the original acquisition failure standing with its reason intact. +const DECLINED_ARTIFACT_SIGNAL_SET = new Set(DECLINED_ARTIFACT_SIGNALS); + async function persistSingleArtifactRecoveryIntent( scope: FiledReturnsDownloadScope, artifactType: FiledReturnsConcreteArtifactType, @@ -672,34 +678,68 @@ async function triggerPageGeneratedSinglePeriodArtifact( checkpointHasDownloadId, externallyVisibleActionMayHaveOccurred, })); - return acquired.ok - ? { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "downloaded", - safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], - safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), - ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic - ? { downloadDiagnostic: acquired.downloadDiagnostic } - : {}), - }, - } - : { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "blocked", - safeSignals: [ - "artifact-acquisition-failed", - `artifact-${acquired.reason}`, - ...acquired.safeSignals, - ], - safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), - }, - }; + if (acquired.ok) { + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "downloaded", + safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], + safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), + ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic + ? { downloadDiagnostic: acquired.downloadDiagnostic } + : {}), + }, + }; + } + + // An uncertain acquisition can already own a browser download or a durable intent. A later + // page inspection cannot establish that no download occurred, so keep that outcome and its + // checkpoint for recovery rather than replacing it with a terminal absence. + const declined = retainCheckpointForRecovery + ? null + : await postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, + }); + if (declined) { + // The retain decision above was made about an acquisition failure. This is not one: the + // portal has established that no download exists for this target, so there is nothing for a + // retry to reconcile. Leaving the intent checkpoint standing would block the next attempt as + // `artifact-acquisition-start-unreconciled` -- refusing the retry this very result offers. + // A worker can stop after the intent is removed but before the outer flow persists this + // terminal answer. Make the answer durable first; only then may `finally` remove the + // recovery checkpoint that kept the failed acquisition restart-safe. + const completionKey = deps.storageKeys.completion; + const persisted = completionKey + ? await persistSinglePeriodSummary({ ...scope, artifactType }, declined.flowStep, { + storageKeys: { completion: completionKey }, + ...(deps.now ? { now: deps.now } : {}), + }) + : null; + retainCheckpointForRecovery = !persisted; + return declined; + } + + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "blocked", + safeSignals: [ + "artifact-acquisition-failed", + `artifact-${acquired.reason}`, + ...acquired.safeSignals, + ], + safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), + }, + }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); @@ -707,6 +747,61 @@ async function triggerPageGeneratedSinglePeriodArtifact( } } +// A terminal no-artifact response is an answer, not a download failure. The content script and +// ledger can record it as unavailable; this message path connects that result to the runner. +// +// This asks, and only when an acquisition has already failed. It cannot turn a failure into a +// download: the step it returns is still `blocked`, and completion still requires correlated +// download evidence. +async function postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, +}: { + artifactType: FiledReturnsConcreteArtifactType; + deps: FiledReturnsFlowMessagingDeps; + requestId: string; + returnType: "GSTR-1" | "GSTR-2B"; + scope: FiledReturnsDownloadScope; + tabId: number; +}): Promise { + const declinable = + (returnType === "GSTR-1" && artifactType === "EXCEL") || returnType === "GSTR-2B"; + if (!declinable) return null; + // This runs after an acquisition has already failed, and it can only refine that failure. If the + // tab has closed, navigated, or refuses injection, the answer is simply that the failure cannot + // be refined -- so the original reason stands. Throwing here would replace a specific, actionable + // failure with the generic background error and lose the terminal summary with it. + let raw: unknown; + try { + raw = await deps.sendMessageToTabWithInjection(tabId, { + type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + payload: { + actionId: requestId, + artifactType, + financialYear: scope.financialYear, + period: scope.period, + returnType, + }, + }); + } catch { + return null; + } + const response = normaliseContentScriptMessageResponse( + raw, + "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + ); + if (!response.ok || !("flowStep" in response)) return null; + // Only the recognised no-details answer is adopted. Any other post-click state leaves the + // original acquisition failure standing, reason intact. + return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNAL_SET.has(signal)) + ? { ok: true, flowStep: response.flowStep } + : null; +} + async function deliverValidatedArtifact({ artifactType, base64, diff --git a/src/background/filed-returns-durable-summary.ts b/src/background/filed-returns-durable-summary.ts index 1a8b4fb2..2995571a 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, @@ -237,6 +227,16 @@ function isConsistentCompleteSummary({ flowStep.downloadDiagnostics === undefined ); } + if ( + flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available") || + (scope.returnType === "GSTR-2B" && flowStep.safeSignals.includes("filed-gstr2b-not-generated")) + ) { + return ( + flowStep.state === "blocked" && + flowStep.downloadDiagnostic === undefined && + flowStep.downloadDiagnostics === undefined + ); + } const artifactType = normaliseFiledReturnsArtifactType(scope.returnType, scope.artifactType); const isSelectedArtifactBundle = artifactType === "PDF_AND_EXCEL"; const hasExactArtifactReconciliation = hasExactArtifactAcquisitionReconciliationEvidence( @@ -357,7 +357,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..7c49de08 100644 --- a/src/background/filed-returns-full-fiscal-year-run-state.ts +++ b/src/background/filed-returns-full-fiscal-year-run-state.ts @@ -6,7 +6,6 @@ import type { FiledReturnsFullFiscalYearLedger, PortalFlowStepResult, } 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"; import { normaliseFiledReturnsArtifactType } from "../connectors/gst/filed-returns-artifacts"; @@ -31,11 +30,16 @@ import { toFullFiscalYearSummary, } from "./filed-returns-full-fiscal-year-summary"; import { persistCanonicalFiledReturnsFlowSummary } from "./filed-returns-session-summary"; +import { + holdsFullFiscalYearTargetAnswer, + isCleanedZipPhase, +} from "../connectors/gst/filed-returns-contracts"; +// 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) => holdsFullFiscalYearTargetAnswer(target.status)); } export function hasDownloadUnconfirmedTarget(ledger: FiledReturnsFullFiscalYearLedger): boolean { diff --git a/src/background/filed-returns-full-fiscal-year-staging.ts b/src/background/filed-returns-full-fiscal-year-staging.ts index 5d3e91cb..d9b889ee 100644 --- a/src/background/filed-returns-full-fiscal-year-staging.ts +++ b/src/background/filed-returns-full-fiscal-year-staging.ts @@ -21,6 +21,7 @@ import { import { durableFullFiscalYearArtifactSignals } from "./filed-returns-full-fiscal-year-validation"; import { discardFullFiscalYearFiledReturnsZip } from "./filed-returns-full-fiscal-year-zip"; import { readCanonicalFiledReturnsFlowSummary } from "./filed-returns-session-summary"; +import { statesFullFiscalYearTargetAbsence } from "../connectors/gst/filed-returns-contracts"; const FULL_YEAR_STAGED_SIGNAL_PREFIX = "full-fiscal-year-opfs-staged:"; @@ -267,7 +268,7 @@ export function markFullFiscalYearRestagingRequired( ): FiledReturnsFullFiscalYearLedger { const timestamp = now.toISOString(); const targets = ledger.targets.map((target) => - target.status === "not-filed" + statesFullFiscalYearTargetAbsence(target.status) ? target : { ...target, diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index eae18533..4cc8c3fc 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -8,9 +8,12 @@ import type { PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; import { + isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, isCleanedZipPhase, zipPhaseProvesDelivery, } from "../connectors/gst/filed-returns-contracts"; +import type { FiledReturnsReturnType } from "../connectors/gst/filed-returns-return-types"; import { filedReturnsArtifactLabel, normaliseFiledReturnsArtifactType, @@ -24,6 +27,7 @@ import { hasInconsistentFullFiscalYearCompletion, isFullFiscalYearLedgerStale, } from "./filed-returns-full-fiscal-year-ledger"; +import { statesFullFiscalYearTargetAbsence } from "../connectors/gst/filed-returns-contracts"; export function fullFiscalYearZipPhaseStep( ledger: FiledReturnsFullFiscalYearLedger, @@ -121,11 +125,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 +140,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", @@ -154,6 +154,7 @@ const TARGET_OUTCOMES: Readonly< export function targetStatusFromFlowStep( step: PortalFlowStepResult, + returnType?: FiledReturnsReturnType, ): FiledReturnsFullFiscalYearTargetStatus { if (step.state === "downloaded") return "downloaded"; if (step.state === "download-unconfirmed") return "download-unconfirmed"; @@ -163,6 +164,17 @@ 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 ( + returnType === "GSTR-2B" && + step.safeSignals.some( + (signal) => + signal === "filed-gstr2b-not-generated" || signal === "artifact-filed-gstr2b-not-generated", + ) + ) { + return "not-generated"; + } if (step.safeSignals.some(isUnconfirmedBrowserDownloadSignal)) { return "download-unconfirmed"; } @@ -238,7 +250,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, @@ -327,8 +339,11 @@ export function fullFiscalYearTargetEvidence( // run produced. Everything that depended on a file Pack no longer has goes. if (runDiscarded || (clearedWithoutDelivery && hadStagedFiles)) { return ledger.targets - .filter((target) => target.status === "not-filed") - .map((target) => ({ period: target.period, outcome: "not-filed" as const })); + .filter((target) => statesFullFiscalYearTargetAbsence(target.status)) + .map((target) => ({ + period: target.period, + outcome: filedReturnsTargetOutcome(target.status, false, false, false), + })); } // From the step as well as the ledger. An MV3 interruption produces a blocked // summary while the persisted ledger normally still reads `running`, so @@ -343,7 +358,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 +403,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, @@ -456,7 +471,7 @@ export function completeFullFiscalYearStep( ...(unplanned.length > 0 ? ["full-fiscal-year-plan-narrower-than-eligible"] : []), ...(ledger.zipPhase === "cleaned-without-export" && ledger.targets.length > 0 && - ledger.targets.every((target) => target.status === "not-filed") + ledger.targets.every((target) => statesFullFiscalYearTargetAbsence(target.status)) ? ["full-fiscal-year-no-zip-artifacts"] : []), ], @@ -494,24 +509,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..3c74236a 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, @@ -32,6 +36,7 @@ import { hasPositiveFiledReturnsDownloadEvidence, isValidFiledReturnsDownloadDiagnosticState, } from "./filed-returns-download-diagnostic-state"; +import { filedReturnsTargetStatusBehaviour } from "../connectors/gst/filed-returns-contracts"; export const FULL_FISCAL_YEAR_PLAN_VERSION = "filed-returns-targets-v3"; @@ -119,17 +124,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 +149,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 +258,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 +375,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" || @@ -452,10 +442,13 @@ function isFullFiscalYearTarget( ) { return false; } - if ( - target.status === "not-filed" && - !target.safeSignals?.includes("filed-return-positively-not-filed") - ) { + // A status is a claim, and the evidence each claim needs is a property of the status rather + // than a rule this file remembers. Spelled out here, only `not-filed` was ever checked, so a + // stored record could assert `not-generated` with nothing behind it. + const requiredEvidenceSignal = filedReturnsTargetStatusBehaviour( + target.status, + ).requiredEvidenceSignal; + if (requiredEvidenceSignal && !target.safeSignals?.includes(requiredEvidenceSignal)) { return false; } return true; 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..b10167e7 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"; @@ -427,7 +428,7 @@ export async function startFullFiscalYearDownloadFlow( systemErrorPredecessor, ), ); - const targetStatus = targetStatusFromFlowStep(flowStep); + const targetStatus = targetStatusFromFlowStep(flowStep, retryScope.returnType); ledger = markFullFiscalYearTargetTerminal( ledger, nextTarget.targetId, @@ -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..fd4eaefa 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", @@ -371,7 +381,7 @@ export function markSinglePeriodBundleArtifactUnavailable( return null; } const diagnostic = optionalArtifactDiagnostic(flowStep, ledger.scope, artifactType); - const missingReason = missingArtifactReason(flowStep); + const missingReason = missingArtifactReason(flowStep, ledger.scope.returnType); if (!missingReason) return null; const updated = updateArtifact(ledger, artifactType, now, { artifactType, @@ -853,13 +863,17 @@ function parsedArtifactPlan( : null; } -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) - ); +function missingArtifactReason( + flowStep: PortalFlowStepResult, + returnType: FiledReturnsDownloadScope["returnType"], +): string | 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)); + const compatible = + (declined === "filed-gstr1-excel-no-details-available" && returnType === "GSTR-1") || + (declined === "filed-gstr2b-not-generated" && returnType === "GSTR-2B"); + return compatible ? (DECLINED_ARTIFACT_REASONS.get(declined) ?? null) : null; } function isMissingReason(value: unknown): value is string { diff --git a/src/background/filed-returns-single-period-flow.ts b/src/background/filed-returns-single-period-flow.ts index 4379aa66..3604df03 100644 --- a/src/background/filed-returns-single-period-flow.ts +++ b/src/background/filed-returns-single-period-flow.ts @@ -50,6 +50,7 @@ import { type Gstr1PeriodMismatchRecovery, type ReturnTypeMismatchRecovery, } from "./filed-returns-gstr1-period-mismatch-recovery"; +import type { ReturnsDashboardAnchorFailureReason } from "../connectors/gst/filed-returns-durable-signals"; // The deadline is the product bound. This only stops a broken zero-delay response loop. const ZERO_DELAY_RUNAWAY_STEP_LIMIT = 10_000; @@ -307,7 +308,7 @@ async function blockedWrongOriginResponse( scope: FiledReturnsDownloadScope, deps: FiledReturnsFlowRunnerDeps, shouldPersistSinglePeriodSummary: boolean, - reason: "ambiguous" | "not-found" | "timeout" | "unavailable", + reason: ReturnsDashboardAnchorFailureReason, ): Promise { return withPersistedSinglePeriodSummary( scope, diff --git a/src/background/filed-returns-single-period-summary.ts b/src/background/filed-returns-single-period-summary.ts index 5aba5f8c..d491b490 100644 --- a/src/background/filed-returns-single-period-summary.ts +++ b/src/background/filed-returns-single-period-summary.ts @@ -15,6 +15,7 @@ import { clearFiledReturnsTargetReview, readFiledReturnsTargetReview, } from "./filed-returns-target-review"; +import { targetStatusFromFlowStep } from "./filed-returns-full-fiscal-year-summary"; export async function withPersistedSinglePeriodSummary( scope: FiledReturnsDownloadScope, @@ -74,10 +75,10 @@ async function responseAfterPersistedSummary( return { ...response, flowSummary }; } -async function persistSinglePeriodSummary( +export async function persistSinglePeriodSummary( scope: FiledReturnsDownloadScope, flowStep: PortalFlowStepResult, - deps: FiledReturnsFlowRunnerDeps, + deps: { storageKeys: { completion: string }; now?: () => Date }, ): Promise { const summary = toSinglePeriodSummary(scope, flowStep, deps.now?.() ?? new Date()); return persistCanonicalFiledReturnsFlowSummary(deps.storageKeys.completion, summary); @@ -97,7 +98,10 @@ function toSinglePeriodSummary( ): FiledReturnsFlowSummary { const isReconciled = flowStep.state === "downloaded" || - flowStep.safeSignals.includes("filed-return-positively-not-filed"); + ["not-filed", "not-generated"].includes(targetStatusFromFlowStep(flowStep, scope.returnType)) || + (scope.returnType === "GSTR-1" && + scope.artifactType === "EXCEL" && + flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available")); const isPartial = flowStep.state === "partial"; return { scope, diff --git a/src/background/gstr2b-artifact-acquisition.ts b/src/background/gstr2b-artifact-acquisition.ts index 09336eca..68697132 100644 --- a/src/background/gstr2b-artifact-acquisition.ts +++ b/src/background/gstr2b-artifact-acquisition.ts @@ -38,7 +38,10 @@ export async function acquirePageGeneratedArtifact(input: { controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, ...(input.returnType === "GSTR-2B" ? { - expectedControlText: artifact.controlText, + // Read straight from the GSTR-2B descriptor: only that return type pins the + // control text here, and GSTR-1 has more than one label for the same artifact. + expectedControlText: + GSTR2B_PAGE_GENERATED_ARTIFACTS[input.artifactType].controlText, expectedPeriodTexts: acceptedFiledReturnsMonthTexts(input.period), } : {}), diff --git a/src/background/local-data.ts b/src/background/local-data.ts index 5a8f2ddc..65cf38b5 100644 --- a/src/background/local-data.ts +++ b/src/background/local-data.ts @@ -1,6 +1,5 @@ import { browser } from "wxt/browser"; import type { FiledReturnsFullFiscalYearLedger } from "../connectors/gst/filed-returns-contracts"; -import { isCleanedZipPhase } from "../connectors/gst/filed-returns-contracts"; import type { PackMessageResponse } from "../connectors/gst/messages"; import { readActiveFiledReturnsRunStorageState, @@ -29,6 +28,10 @@ import { clearAllSupportedFullFiscalYearLedgerPlans, readAllSupportedPlanLedgersStorageStateWithinOperation, } from "./filed-returns-all-supported-full-fiscal-year-run-state"; +import { + isCleanedZipPhase, + isResolvedFullFiscalYearTargetStatus, +} from "../connectors/gst/filed-returns-contracts"; export interface PackLocalDataDeps { clearableLocalStorageKeys: readonly string[]; @@ -209,16 +212,10 @@ function hasUnresolvedZipState(ledger: { function isUnresolvedFullFiscalYearLedger(ledger: FiledReturnsFullFiscalYearLedger): boolean { if (hasInconsistentFullFiscalYearCompletion(ledger)) return true; if (ledger.status === "complete" || ledger.status === "cancelled") return false; - return ledger.targets.some((target) => - [ - "pending", - "running", - "download-unconfirmed", - "blocked", - "failed", - "manually-observed", - ].includes(target.status), - ); + // The same question `filed-returns-current-state.ts` asks when it decides which ledger to show, + // and now the same answer. The two lists had drifted: this one omitted `cancelled`, so a ledger + // the panel was still surfacing could be cleared from under it. + return ledger.targets.some((target) => !isResolvedFullFiscalYearTargetStatus(target.status)); } async function readLocalValue(key: string): Promise { diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index f2ab2da6..8ac82dcf 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -1,15 +1,14 @@ -import { validateArtifactBytes } from "./artifact-validation"; +import { describeJsonArtifactRejection, validateArtifactBytes } from "./artifact-validation"; import { GSTR1_DETAIL_PATH, GSTR1_PAGE_GENERATED_ARTIFACTS, - GSTR1_SUMMARY_PATH, GSTR1_SUMMARY_PREFLIGHT_PATH, GSTR2B_JSON_PATH, GSTR2B_ORIGIN, GSTR2B_PAGE_GENERATED_ARTIFACTS, GSTR2B_SUMMARY_PATH, + findPageArtifactControls, } from "./portal-artifact-endpoints"; -import { getClickableElements, normaliseText } from "./filed-returns-dom"; import { extractScopedFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { filedReturnDetailIdentityMatchesScope } from "./filed-returns-detail-navigation"; import { resolveVisibleFiledReturnDownloadCandidates } from "./filed-returns-download-candidates"; @@ -224,14 +223,37 @@ async function acquireGstr1Artifact( const preflightBytes = new Uint8Array(await response.arrayBuffer()); if (isHtmlResponse(preflightBytes)) return failed(request, "preflight-failed"); const preflight = validateArtifactBytes(preflightBytes, "JSON", request.returnPeriod, "GSTR-1"); - if (!preflight.ok) return failed(request, preflight.reason); - const expectedPath = request.artifactType === "PDF" ? GSTR1_SUMMARY_PATH : GSTR1_DETAIL_PATH; - if (view.location.pathname !== expectedPath) - return failed(request, "wrong-page", ["target-period-verified"]); + if (!preflight.ok) { + return failed(request, preflight.reason, [ + "gstr1-summary-preflight-rejected", + ...describeJsonArtifactRejection(preflightBytes, request.returnPeriod, "GSTR-1"), + ]); + } const descriptor = GSTR1_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); - if (controls.length !== 1 || !controls[0]) - return failed(request, "control-not-found", ["target-period-verified"]); + // The page decides which control to look for, because the same artifact is labelled differently + // on each surface that offers it. A page that is not one of those surfaces is refused. + const pathname = view.location.pathname.replace(/\/$/u, "") || "/"; + const surface = descriptor.surfaces.find((candidate) => candidate.path === pathname); + if (!surface) { + // Named symbolically rather than by path, so the reason is diagnosable without a portal URL + // reaching a signal, a log, or an issue. + return failed(request, "wrong-page", [ + "target-period-verified", + request.artifactType === "PDF" + ? "gstr1-pdf-expects-summary-page" + : "gstr1-excel-expects-detail-page", + pathname === GSTR1_DETAIL_PATH ? "gstr1-on-detail-page" : "gstr1-on-other-page", + ]); + } + const controls = findPageArtifactControls(documentRef, surface.controlText); + if (controls.length !== 1 || !controls[0]) { + // How many matched matters: none means the label is wrong for this page shape, several means + // the label is ambiguous and binding to one of them would be a guess. + return failed(request, "control-not-found", [ + "target-period-verified", + controls.length === 0 ? "gstr1-control-label-unmatched" : "gstr1-control-label-ambiguous", + ]); + } const pageTargetMismatchSignals = gstr1PageTargetMismatchSignals(controls[0], request); if (pageTargetMismatchSignals.length > 0) { return failed(request, "page-period-mismatch", [ @@ -309,7 +331,7 @@ async function acquireGstr2bArtifact( if (view.location.pathname !== GSTR2B_SUMMARY_PATH) return failed(request, "wrong-page", ["target-period-verified"]); const descriptor = GSTR2B_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); + const controls = findPageArtifactControls(documentRef, descriptor.controlText); if (controls.length !== 1 || !controls[0]) return failed(request, "control-not-found", ["target-period-verified"]); // The preflight above validated the fetched JSON, not the page. The summary @@ -339,15 +361,6 @@ async function acquireGstr2bArtifact( }; } -function resolvePageArtifactControls(documentRef: Document, canonicalLabel: string): HTMLElement[] { - const normalisedLabel = normaliseText(canonicalLabel); - return getClickableElements(documentRef).filter( - (element) => - getClickableElements(element).length === 0 && - normaliseText(element.textContent || "").includes(normalisedLabel), - ); -} - function failed( request: ArtifactRequest, reason: ArtifactFailureReason, diff --git a/src/connectors/gst/artifact-validation.ts b/src/connectors/gst/artifact-validation.ts index beaf7ac9..e72c898c 100644 --- a/src/connectors/gst/artifact-validation.ts +++ b/src/connectors/gst/artifact-validation.ts @@ -1,4 +1,5 @@ import type { FiledReturnsReturnType } from "./filed-returns-return-types"; +import type { JsonArtifactRejectionSignal } from "./filed-returns-acquisition-diagnostics"; export type ArtifactValidationResult = | { @@ -11,7 +12,7 @@ export type ArtifactValidationResult = | { ok: false; reason: "empty" | "too-large" | "unexpected-content" | "target-period-mismatch" }; const MIN_PDF_BYTES = 1024; -const MIN_JSON_BYTES = 100; +const MIN_NON_GSTR1_JSON_BYTES = 100; export const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024; const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; const XLSX_MAGIC = [0x50, 0x4b]; @@ -65,7 +66,14 @@ export function validateArtifactBytes( mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }; } - if (bytes.byteLength < MIN_JSON_BYTES) return { ok: false, reason: "unexpected-content" }; + // GSTR-1 accepts a compact summary envelope, and a byte count alone cannot distinguish that + // contract from a truncated response. + // Every case the floor was standing in for is caught below and caught better: an empty body is + // rejected above, a non-JSON body fails to parse, and a body too small to hold the envelope + // fails the envelope and period checks that follow. + if (returnType !== "GSTR-1" && bytes.byteLength < MIN_NON_GSTR1_JSON_BYTES) { + return { ok: false, reason: "unexpected-content" }; + } try { const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; const contract = filedReturnsJsonDocumentContract(returnType); @@ -83,6 +91,52 @@ export function validateArtifactBytes( } } +// Why a JSON artifact was refused, as signal fragments safe to show and to persist. +// +// `validateArtifactBytes` returns a category, and two different failures share the +// `unexpected-content` category: a body that is not the expected envelope, and one whose period +// field is absent. Diagnosing a live refusal from outside the browser needs the difference, and a +// guard that cannot say which condition fired costs a round trip every time it fires. +// +// Everything here is a shape fact -- byte count band, field presence, parse success. No field +// value is included, so nothing taxpayer-identifying can reach a signal. +export function describeJsonArtifactRejection( + bytes: Uint8Array, + expectedReturnPeriod: string, + returnType: FiledReturnsReturnType, +): JsonArtifactRejectionSignal[] { + if (bytes.byteLength === 0) return ["json-body-empty"]; + // The size cap is a processing bound, not only a verdict. Decoding and parsing a body this + // function has already been told is too large spends exactly the work the cap exists to refuse, + // on the one path where the input is known to be unreasonable. The band is the whole diagnostic + // here: nothing inside an oversized body would change what a reader does about it. + if (bytes.byteLength > MAX_ARTIFACT_BYTES) return ["json-body-oversized"]; + // Report the size band and keep going. Stopping here says only that the body is small, which + // cannot distinguish a legitimately compact envelope from a truncated or unrelated response -- + // and that distinction is the whole question when a return has nothing in it. + const signals: JsonArtifactRejectionSignal[] = []; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + return [...signals, "json-parse-failed"]; + } + const contract = filedReturnsJsonDocumentContract(returnType); + if (contract.requiredStatus !== undefined) { + const statusMatches = isJsonObject(parsed) && parsed.status === contract.requiredStatus; + if (!statusMatches) return [...signals, "json-status-unexpected"]; + } + const document = jsonObjectAtPath(parsed, contract.envelopePath); + if (!document) return [...signals, "json-envelope-missing"]; + const actualPeriod = document[contract.returnPeriodKey]; + if (typeof actualPeriod !== "string") return [...signals, "json-period-field-missing"]; + // A small body that still satisfies the contract is the case the size floor gets wrong, and + // this signal is what says so. + return actualPeriod === expectedReturnPeriod + ? [...signals, "json-contract-satisfied"] + : [...signals, "json-period-mismatch"]; +} + function jsonObjectAtPath(input: unknown, path: readonly string[]): Record | null { let current = input; for (const segment of path) { diff --git a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts new file mode 100644 index 00000000..1d117734 --- /dev/null +++ b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts @@ -0,0 +1,74 @@ +// The signals that say why an artifact acquisition was refused. +// +// A leaf module with no imports, because two places need the same list: the code that emits each +// signal, and the durable allowlist that decides whether a refusal can be persisted. +// +// That coupling is the point. An unregistered signal does not degrade gracefully -- it rejects the +// whole durable array, and the run then halts on non-canonical recovery metadata instead of +// recording the refusal the signal was describing. Emitting and persisting therefore read from one +// list rather than two that can drift, which is how the first version of these diagnostics broke +// the full-year run it was built to explain. +// +// The coupling is enforced by the types below rather than by asking each emitter to import a +// constant: an emitter declares it returns one of these, so a literal that is not on the list +// fails to compile. That is the level the last failure of this kind needed -- the list was right +// and the emitter simply wrote a string nobody had registered. +// +// Every entry is a Pack-owned constant. None carries portal text, a field value, or a path, so +// nothing portal-derived reaches durable state through them. +export const JSON_ARTIFACT_REJECTION_SIGNALS = [ + // No longer emitted: the JSON contract now determines whether a compact envelope is valid. It + // stays registered so durable state written by an earlier build remains readable. + "json-body-under-minimum", + "json-body-empty", + "json-body-oversized", + "json-parse-failed", + "json-status-unexpected", + "json-envelope-missing", + "json-period-field-missing", + "json-period-mismatch", + "json-contract-satisfied", +] as const; + +export const GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + "gstr1-summary-preflight-rejected", + "gstr1-pdf-expects-summary-page", + "gstr1-excel-expects-detail-page", + "gstr1-on-detail-page", + "gstr1-on-other-page", + "gstr1-control-label-unmatched", + "gstr1-control-label-ambiguous", +] as const; + +export const ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + ...JSON_ARTIFACT_REJECTION_SIGNALS, + ...GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS, +] as const; + +export type JsonArtifactRejectionSignal = (typeof JSON_ARTIFACT_REJECTION_SIGNALS)[number]; +export type Gstr1AcquisitionDiagnosticSignal = + (typeof GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS)[number]; + +// The portal's own refusals to produce an artifact: a filed GSTR-1 with no e-invoices to report, +// and a GSTR-2B the portal did not draft. Both are answers rather than faults, and a run records +// the artifact as unavailable and carries on. +// +// Three structures held this pair before -- a signal set, a reason set, and a map between them -- +// in two modules, with the `artifact-` prefix that relates them stated nowhere. Adding the second +// refusal meant editing all three, and the relationship was only ever visible by reading them side +// by side. +export const DECLINED_ARTIFACT_SIGNALS = [ + "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", +] as const; + +export type DeclinedArtifactSignal = (typeof DECLINED_ARTIFACT_SIGNALS)[number]; + +/** The reason recorded against the artifact, derived from the flow signal that carried it. */ +export function declinedArtifactReason( + signal: DeclinedArtifactSignal, +): `artifact-${DeclinedArtifactSignal}` { + return `artifact-${signal}`; +} + +export const DECLINED_ARTIFACT_REASONS = DECLINED_ARTIFACT_SIGNALS.map(declinedArtifactReason); diff --git a/src/connectors/gst/filed-returns-contracts.ts b/src/connectors/gst/filed-returns-contracts.ts index 4a8a9d90..fba2ff83 100644 --- a/src/connectors/gst/filed-returns-contracts.ts +++ b/src/connectors/gst/filed-returns-contracts.ts @@ -227,16 +227,160 @@ 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); +} + +/** + * What a status *means*, in one place, answered for every member. + * + * The questions below were previously asked as literal comparisons scattered across twenty-two + * modules. Nothing connected them, so each was an independent place to forget: `not-generated` was + * added to the union and five separate sites went on spelling the question `=== "not-filed"`, + * silently answering "no" for every period the portal declined to draft. + * + * A `Record` over the union is what makes that impossible. Adding a member does not compile until + * every question below has been answered for it -- which is the difference between this and the + * `Set` that used to live here, under a comment warning against exactly the `Set`. + */ +interface FiledReturnsTargetStatusBehaviour { + /** The run is still working on this by itself. Nobody needs to decide anything. */ + active: boolean; + /** The portal has answered. Retrying cannot change the outcome, so a run counts it as done. */ + resolved: boolean; + /** + * The portal stated that no artifact exists for this period. + * + * Deliberately one question with two members behind it. `not-filed` is a claim about the + * taxpayer and `not-generated` is a claim about the portal, so they must stay distinct wherever + * a person reads them -- but "is there a file to expect?" has the same answer for both, and + * every site that asked it by naming only `not-filed` was wrong. + */ + statedAbsence: boolean; + /** A local file was staged for this target. Narrower than `resolved`: an absence stages nothing. */ + producedFile: boolean; + /** + * Holds an answer a run must not discard or overwrite. + * + * Wider than `resolved`: a manually observed target was answered by a person rather than the + * portal, which does not resolve it but is still work that took a human and cannot be replaced. + */ + holdsAnswer: boolean; + /** + * The signal a stored record claiming this status must carry, or `null` where none applies. + * + * A status is a claim, and a claim without its evidence is how a ledger comes back asserting + * something no run established. `downloaded` is `null` here because its evidence is a richer + * predicate than a signal name -- see `hasPositiveFiledReturnsDownloadEvidence`. + */ + requiredEvidenceSignal: string | null; +} + +const TARGET_STATUS_BEHAVIOUR: Readonly< + Record +> = { + pending: base({ active: true }), + running: base({ active: true }), + downloaded: base({ resolved: true, producedFile: true, holdsAnswer: true }), + "manually-observed": base({ holdsAnswer: true }), + "not-filed": base({ + resolved: true, + statedAbsence: true, + holdsAnswer: true, + requiredEvidenceSignal: "filed-return-positively-not-filed", + }), + "not-generated": base({ + resolved: true, + statedAbsence: true, + holdsAnswer: true, + requiredEvidenceSignal: "filed-gstr2b-not-generated", + }), + "download-unconfirmed": base({}), + blocked: base({}), + failed: base({}), + cancelled: base({}), +}; + +/** Every question answers "no" unless a status says otherwise, so a new member starts inert. */ +function base( + overrides: Partial, +): FiledReturnsTargetStatusBehaviour { + return { + active: false, + resolved: false, + statedAbsence: false, + producedFile: false, + holdsAnswer: false, + requiredEvidenceSignal: null, + ...overrides, + }; +} + +export function filedReturnsTargetStatusBehaviour( + status: FiledReturnsFullFiscalYearTargetStatus, +): FiledReturnsTargetStatusBehaviour { + return TARGET_STATUS_BEHAVIOUR[status]; +} + +export function isResolvedFullFiscalYearTargetStatus( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + return TARGET_STATUS_BEHAVIOUR[status].resolved; +} + +/** The portal said there is no artifact here -- whoever it made the claim about. */ +export function statesFullFiscalYearTargetAbsence( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + return TARGET_STATUS_BEHAVIOUR[status].statedAbsence; +} + +/** An answer a run must not discard or overwrite, whether the portal or a person gave it. */ +export function holdsFullFiscalYearTargetAnswer( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + return TARGET_STATUS_BEHAVIOUR[status].holdsAnswer; +} + +/** + * 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 active belongs here by + * definition, so a new one cannot land in neither bucket. + */ +export function needsExplicitFullFiscalYearRetry( + status: FiledReturnsFullFiscalYearTargetStatus, +): boolean { + const behaviour = TARGET_STATUS_BEHAVIOUR[status]; + return !behaviour.resolved && !behaviour.active; +} export interface FiledReturnsFullFiscalYearTarget { targetId: string; @@ -374,6 +518,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-declined-artifact.ts b/src/connectors/gst/filed-returns-declined-artifact.ts new file mode 100644 index 00000000..493b8561 --- /dev/null +++ b/src/connectors/gst/filed-returns-declined-artifact.ts @@ -0,0 +1,195 @@ +import type { PortalDownloadTriggerResult } from "../../core/contracts"; +import type { + FiledReturnsDownloadScope, + FiledReturnsDownloadTarget, +} from "./filed-returns-contracts"; +import { type DeclinedArtifactSignal } from "./filed-returns-acquisition-diagnostics"; +import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; +import { filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; + +// Recording a refusal resolves a target outright: the period is answered, the run advances, and +// no artifact ever follows to corroborate it. The visible page is therefore the whole of the +// evidence, and a refusal read from a page that was never checked against the target is a wrong +// answer that looks exactly like a right one. +// +// Three emitters each learned that separately, one reported defect at a time, and nothing stopped +// a fourth from not learning it. So the check is no longer something an emitter remembers to do: +// a declined result cannot be constructed without presenting proof that it was done. + +declare const boundToVisibleTarget: unique symbol; + +/** + * Proof that the visible page was checked against the target this refusal is about. + * + * The brand is a type-only symbol declared here and exported nowhere, so no object literal + * written in another module satisfies this interface and no emitter can reach + * `declinedArtifactStep` without first having run a guard below. A deliberate + * `as unknown as VisibleTargetBinding` would still get through -- this makes the check + * impossible to *forget*, which is how all four of these defects happened, not impossible to + * circumvent on purpose. + */ +export interface VisibleTargetBinding< + Signal extends DeclinedArtifactSignal, + ReturnType extends FiledReturnsDownloadScope["returnType"], +> { + readonly [boundToVisibleTarget]: true; + /** The portal answer this proof may construct. */ + readonly signal: Signal; + /** The target family the proof was bound against. */ + readonly returnType: ReturnType; + /** What the guard established, carried into the result so the record says how it was checked. */ + readonly safeSignals: readonly RefusalBindingSignal[]; +} + +/** + * What each binder establishes, named so the durable record says *how* the refusal was checked. + * + * Registered by derivation rather than by hand: the durable allowlist spreads this list, so a new + * binder's signal is admitted the moment it is written here. One unregistered token rejects the + * entire array it travels in, and a refusal is a terminal step whose signals are persisted -- + * so a terminal refusal remains readable after persistence. + */ +export const REFUSAL_BINDING_SIGNALS = [ + "filed-gstr1-detail-period-verified", + "gstr2b-visible-period-verified", +] as const; + +export type RefusalBindingSignal = (typeof REFUSAL_BINDING_SIGNALS)[number]; + +export type RefusalBinding< + Signal extends DeclinedArtifactSignal, + ReturnType extends FiledReturnsDownloadScope["returnType"], +> = + | { readonly bound: VisibleTargetBinding } + | { readonly bound: null; readonly mismatch: PortalDownloadTriggerResult }; + +// The brand exists only in the type system, so it is asserted rather than written. This is the +// one place allowed to make that assertion, which is what the brand is for. +function bound< + Signal extends DeclinedArtifactSignal, + ReturnType extends FiledReturnsDownloadScope["returnType"], +>( + signal: Signal, + returnType: ReturnType, + safeSignals: readonly RefusalBindingSignal[], +): RefusalBinding { + return { + bound: { signal, returnType, safeSignals } as unknown as VisibleTargetBinding< + Signal, + ReturnType + >, + }; +} + +function incompatibleScope( + returnType: FiledReturnsDownloadScope["returnType"], +): PortalDownloadTriggerResult { + return { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "blocked", + safeSignals: ["page-target-unverified"], + safeMessage: "Pack could not bind this portal refusal to the requested return type.", + }; +} + +/** + * The filed GSTR-1 detail route, against the target whose artifact was declined. + * + * The same guard that binds a download click on that page, asked the same question: this return + * type, this period, this financial year, as the page itself shows them. It fails closed -- an + * unreadable detail header is "could not determine", never "matches". + */ +export function bindGstr1DetailRefusal( + documentRef: Document, + target: FiledReturnsDownloadTarget, +): RefusalBinding<"filed-gstr1-excel-no-details-available", "GSTR-1"> { + if (target.returnType !== "GSTR-1" || target.artifactType !== "EXCEL") + return { bound: null, mismatch: incompatibleScope(target.returnType) }; + const mismatch = verifyFiledReturnsDownloadTarget(documentRef, target, []); + return mismatch + ? { bound: null, mismatch } + : bound("filed-gstr1-excel-no-details-available", "GSTR-1", [ + "filed-gstr1-detail-period-verified", + ]); +} + +/** + * The GSTR-2B summary route, against the period whose statement the portal declined to draft. + * + * Visible evidence is required rather than accepted from the page's own inline configuration: + * a download click may lean on that configuration because the file it produces is correlated to + * the target afterwards, and a refusal has no such second source. + */ +export function bindGstr2bSummaryRefusal( + documentRef: Document, + normalisedText: string, + scope: FiledReturnsDownloadScope, +): RefusalBinding<"filed-gstr2b-not-generated", "GSTR-2B"> { + if (scope.returnType !== "GSTR-2B") + return { bound: null, mismatch: incompatibleScope(scope.returnType) }; + const mismatch = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope, true); + return mismatch + ? { bound: null, mismatch } + : bound("filed-gstr2b-not-generated", "GSTR-2B", ["gstr2b-visible-period-verified"]); +} + +/** + * What a reader is told when the portal declines, and what Pack offers them to do about it. + * + * A `Record` over the declined signals, so a third refusal cannot be registered without deciding + * both. The GSTR-2B wording lived in two places before this and was identical in both, which is + * the duplicate nothing could contradict: no test compares one emitter's copy with another's. + */ +const DECLINED_ARTIFACT_COPY: Readonly< + Record +> = { + "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.", + userActionMessage: + "Close the GST Portal information dialog, then retry the GSTR-1 Excel download after e-invoice details are available.", + }, + "filed-gstr2b-not-generated": { + safeMessage: + "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.", + userActionMessage: + "Check the GST Portal's stated reason for this period. Retry only once the portal generates a GSTR-2B for it.", + }, +}; + +/** The wording a reader is given, for the durable record that outlives the flow step. */ +export function declinedArtifactSafeMessage(signal: DeclinedArtifactSignal): string { + return DECLINED_ARTIFACT_COPY[signal].safeMessage; +} + +/** + * The only way to build a declined-artifact result, and it takes the proof as its first argument. + * + * Always `blocked`: this records an absence the portal stated, never a download. Completion still + * requires correlated download evidence, which an absence by definition does not have. + */ +export function declinedArtifactStep< + Signal extends DeclinedArtifactSignal, + ReturnType extends FiledReturnsDownloadScope["returnType"], +>( + binding: VisibleTargetBinding, + options: { safeSignals: readonly string[] }, +): PortalDownloadTriggerResult { + const copy = DECLINED_ARTIFACT_COPY[binding.signal]; + return { + connectorId: "gst", + scopeId: filedReturnScopeId(binding.returnType), + state: "blocked", + safeSignals: Array.from( + new Set([...options.safeSignals, ...binding.safeSignals, binding.signal]), + ), + safeMessage: copy.safeMessage, + userAction: { + type: "RETRY_PORTAL_GENERATION", + message: copy.userActionMessage, + canResume: true, + }, + }; +} diff --git a/src/connectors/gst/filed-returns-detail-navigation.ts b/src/connectors/gst/filed-returns-detail-navigation.ts index 13f4cc52..c6833a29 100644 --- a/src/connectors/gst/filed-returns-detail-navigation.ts +++ b/src/connectors/gst/filed-returns-detail-navigation.ts @@ -8,6 +8,7 @@ import { import type { extractFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { navigateToReturnDashboardPage } from "./filed-returns-navigator"; import { filedReturnDescriptor, filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { offersFiledGstr1DetailPdf } from "./portal-artifact-endpoints"; export function shouldReturnFromMismatchedDetail( detailIdentity: ReturnType, @@ -150,6 +151,14 @@ export function clickFiledGstr1SummaryForPdf( if (scope.returnType !== "GSTR-1") return null; if (!scopeIncludesPdfArtifact(scope)) return null; if (safeSignals.includes("download-pdf-gstr-1")) return null; + // The detail route can offer the filed-PDF control directly. Leaving a page that already offers + // the download makes the flow wait for a navigation step that is not needed. + // + // Ask the page's controls, not a signal derived from its text. That signal only says the label + // appears somewhere, which decoy or non-actionable copy also does -- and + // skipping a real View Summary control on that basis strands a target whose PDF was reachable + // all along. The question is whether this page offers something to click, so the page is asked. + if (offersFiledGstr1DetailPdf(documentRef)) return null; if (isGstr1SummaryRoute(documentRef)) return null; const summaryControl = findGstr1ViewSummaryControl(documentRef); diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index 08dca878..f37f6c91 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -1,3 +1,4 @@ +import { REFUSAL_BINDING_SIGNALS } from "./filed-returns-declined-artifact"; import { FILED_RETURNS_WORKBOOK_ABSENCE_OUTCOMES } from "./offscreen-blob-url"; import { FILED_RETURNS_MONTHS } from "./filed-returns-scope"; import type { FiledReturnsReturnType } from "./filed-returns-return-types"; @@ -25,6 +26,25 @@ import { FILED_RETURNS_TARGET_REVIEW_CLEAR_FAILURE_STAGES, filedReturnsTargetReviewClearFailureSignal, } from "./filed-returns-target-review-clear"; +import { + ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + DECLINED_ARTIFACT_REASONS, + DECLINED_ARTIFACT_SIGNALS, +} from "./filed-returns-acquisition-diagnostics"; + +// The reasons and methods those templates can produce. Enumerated here beside the allowlist so a +// new one is a compile-time change in one place rather than a signal that silently fails to +// persist. +export const RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS = [ + "ambiguous", + "not-found", + "timeout", + "unavailable", +] as const; +export const PORTAL_BLOB_SHIM_SUPPRESSION_METHODS = ["dispatchEvent", "click"] as const; + +export type ReturnsDashboardAnchorFailureReason = + (typeof RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS)[number]; const MAX_DURABLE_SIGNAL_COUNT = 32; @@ -132,7 +152,10 @@ const EXACT_DURABLE_SIGNALS = new Set([ "filed-gstr1-download-status-not-filed", "filed-gstr1-download-trigger-ambiguous", "filed-gstr1-excel-control-pending", - "filed-gstr1-excel-no-details-available", + // Both portal refusals, from the list that defines them, so registering a new one is not a + // separate step someone can forget -- which is how the last one halted a run. + ...DECLINED_ARTIFACT_SIGNALS, + ...REFUSAL_BINDING_SIGNALS, GSTR1_PERIOD_MISMATCH_RECOVERY_STOPPED_SIGNAL, "filed-gstr1-result-view-auto-attempt-failed", "filed-gstr1-result-view-auto-clicked", @@ -345,6 +368,18 @@ const EXACT_DURABLE_SIGNALS = new Set([ "gstr1-artifact-response-missing", "gstr1-artifact-state-invalid", "gstr2b-detail-heading", + // Page-identity evidence for the GSTR-2B summary route. These were transient while the only + // step that carried them was the "ready" hand-off to acquisition, whose signals never reach + // durable state. Recording the portal's refusal made them terminal, and one unregistered token + // rejects the whole array -- which blocked every period the portal declined to generate. + "gstr2b-visible-period-verified", + "gstr2b-visible-period-mismatch", + "gstr2b-labelled-period-evidence-missing", + "gstr2b-server-period-mismatch", + "gstr2b-server-visible-period-conflict", + "gstr2b-summary-period-mismatch", + "gstr2b-summary-dashboard-back-clicked", + "gstr2b-summary-back-clicked", "gstr2b-detail-route", "gstr2b-dashboard-period-select-found", "gstr2b-dashboard-period-select-missing", @@ -387,10 +422,7 @@ const EXACT_DURABLE_SIGNALS = new Set([ "return-dashboard-after-returns-menu", "return-dashboard-after-services-menu", "return-dashboard-initial-scan", - "returns-dashboard-anchor-ambiguous", - "returns-dashboard-anchor-not-found", - "returns-dashboard-anchor-timeout", - "returns-dashboard-anchor-unavailable", + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map((reason) => `returns-dashboard-anchor-${reason}`), "return-filing-period-left-unselected", "return-type-selected", "safe-dialog-dismissed", @@ -634,7 +666,7 @@ const SCOPED_RETURN_SIGNAL_SUFFIXES = new Set([ ]); const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-failed", - "artifact-filed-gstr1-excel-no-details-available", + ...DECLINED_ARTIFACT_REASONS, // Artifact-acquisition recovery exists to survive service-worker death, so // its outcomes must be persistable. Without these the blocked summary that // routes an interrupted acquisition to review is rejected by @@ -662,6 +694,20 @@ const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-download-completed-unpersisted", "artifact-acquisition-download-reconciled", ...Object.keys(ARTIFACT_FAILURE_MESSAGES).map((reason) => `artifact-${reason}`), + // Why an acquisition was refused, not just that it was. Spread from the same list the emitters + // read, so a new diagnostic cannot be added without becoming persistable -- an unregistered one + // rejects the whole array and halts the run on non-canonical recovery metadata. + ...ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + // Built by template rather than written as literals, which is why a scan for unregistered + // signal strings never found them. Each accompanies a blocked step that has to persist, and an + // unregistered one rejects the whole array -- the run then halts on non-canonical recovery + // metadata rather than on the navigation problem it was describing. + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + // Passed as the step-limit signal for the GSTR-1 Excel detail wait. Its `gstr1` prefix puts it + // in the navigation rejection category, so an unregistered one rejects the whole array. + "gstr1-excel-detail-step-limit-reached", ]); export function parseDurableFiledReturnsSignals(input: unknown): string[] | null { @@ -686,6 +732,13 @@ export function durableFiledReturnsSignalRejectionReason( // persisted or rendered. It distinguishes Pack-owned producer families during // live recovery without admitting portal-derived text into durable state. function durableUnknownSignalCategory(signal: string): DurableFiledReturnsSignalRejectionReason { + // The token itself is never logged. This boundary exists precisely because it cannot know what + // it has been handed -- a legacy or malformed entry read back from storage is exactly the input + // it is here to refuse -- so repeating it in a console sink undoes the refusal it just made. + // + // The diagnostic this replaces was written when an unregistered signal could only be found at + // runtime. It no longer can: the signal lists are now types, and a producer emitting a token the + // allowlist does not carry fails to compile. A category is what remains useful at runtime. if (signal.startsWith("filed-return-detail-")) return "unknown-detail-identity"; if ( /^(?:artifact-|filed-gstr|page-|browser-download|full-fiscal-year-opfs|single-period-opfs)/.test( diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index 63ec33d2..65f02958 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -1,3 +1,4 @@ +import { declinedArtifactSafeMessage } from "./filed-returns-declined-artifact"; 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" @@ -116,6 +118,12 @@ export function parseDurableTargetStatus( ): { safeMessage: string; safeSignals: string[] } | null { const safeSignals = parseDurableFiledReturnsSignals(inputSignals); if (!safeSignals) return null; + if ( + status === "not-generated" && + (scope.returnType !== "GSTR-2B" || !safeSignals.includes("filed-gstr2b-not-generated")) + ) { + return null; + } return { safeSignals, safeMessage: canonicalDurableTargetMessage(scope, status, safeSignals), @@ -476,6 +484,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 +656,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": declinedArtifactSafeMessage("filed-gstr2b-not-generated"), 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-observer-signals.ts b/src/connectors/gst/filed-returns-observer-signals.ts index 7b71ce26..f047d300 100644 --- a/src/connectors/gst/filed-returns-observer-signals.ts +++ b/src/connectors/gst/filed-returns-observer-signals.ts @@ -1,6 +1,7 @@ import { filedReturnScopedSignal } from "./filed-returns-return-descriptors"; import type { FiledReturnsReturnType } from "./filed-returns-return-types"; import type { FiledReturnsObservationHints } from "./filed-returns-observer-types"; +import { filedReturnDescriptor } from "./filed-returns-return-descriptors"; // Fixed classifier vocabulary only. These tokens carry no portal text or taxpayer // data and are shared by the observation and durable-recovery boundaries. @@ -86,11 +87,18 @@ function addReturnTextSignals(text: string, signals: string[]): void { } function addDownloadControlSignals(text: string, signals: string[]): void { - if (/download filed gstr[\s-]?3b/.test(text)) signals.push("download-filed-gstr-3b"); - if (/download filed gstr[\s-]?1\b/.test(text)) signals.push("download-filed-gstr-1"); + // Read the control patterns from the descriptors rather than restating them. This file used to + // carry its own copies, so a portal label the descriptor learned about stayed unrecognised here + // -- which is how `DOWNLOAD FILED (PDF)` was missed by both layers at once. + const gstr1 = filedReturnDescriptor("GSTR-1"); + if (filedReturnDescriptor("GSTR-3B").explicitDownloadPattern.test(text)) { + signals.push("download-filed-gstr-3b"); + } + if (gstr1.explicitDownloadPattern.test(text)) signals.push("download-filed-gstr-1"); if ( signals.includes("gstr-1-summary-route") && - (/\bdownload\s*\(?\s*pdf\s*\)?\b/.test(text) || /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) + ((gstr1.secondaryDownloadPattern?.test(text) ?? false) || + /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) ) { signals.push("download-pdf-gstr-1"); } 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..451ceb12 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -1,37 +1,43 @@ 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"; +import { + bindGstr1DetailRefusal, + bindGstr2bSummaryRefusal, + declinedArtifactStep, +} from "./filed-returns-declined-artifact"; +import { normaliseText } from "./filed-returns-dom"; +import { readDocumentText } from "./gstr2b-summary"; -const GSTR1_EXCEL_POST_CLICK_BLOCKED_WAIT_MS = 800; -const GSTR1_EXCEL_POST_CLICK_BLOCKED_POLL_MS = 100; +// A recognised terminal absence is an answer rather than a download failure. Retrying it within +// the same run cannot produce a file, so it must be recorded as unavailable. +// +// 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( +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) @@ -39,23 +45,40 @@ export function detectPostClickBlockedState( return null; } - return { - connectorId: "gst", - scopeId: filedReturnScopeId(target.returnType), - state: "blocked", - safeSignals: [ - ...safeSignals, - ...(safeSignals.includes("filed-gstr1-excel-no-details-available") - ? [] - : ["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: { - type: "RETRY_PORTAL_GENERATION", - message: - "Close the GST Portal information dialog, then retry the GSTR-1 Excel download after e-invoice details are available.", - canResume: true, - }, - }; + // A dialog on screen is not bound to this target by being on screen. The detail route does not + // change per period, and a dialog left standing by an earlier target would otherwise mark this + // artifact unavailable -- letting a composite or full-year run carry on having silently omitted + // an artifact the portal never declined for it. + const binding = bindGstr1DetailRefusal(documentRef, target); + if (!binding.bound) return null; + + return declinedArtifactStep(binding.bound, { + safeSignals, + }); +} + +// Match the terminal outcome, not surrounding advisory copy: the latter may change independently +// of whether an artifact is available. +export function isGstr2bNotGeneratedText(pageText: string): boolean { + return /\bgstr[\s-]?2b\s+could\s+not\s+be\s+generated\b/i.test(pageText); +} + +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. + const binding = bindGstr2bSummaryRefusal(documentRef, normalised, target); + if (!binding.bound) return null; + + return declinedArtifactStep(binding.bound, { + safeSignals, + }); } diff --git a/src/connectors/gst/filed-returns-return-descriptors.ts b/src/connectors/gst/filed-returns-return-descriptors.ts index a34d7732..ea4ab729 100644 --- a/src/connectors/gst/filed-returns-return-descriptors.ts +++ b/src/connectors/gst/filed-returns-return-descriptors.ts @@ -31,7 +31,11 @@ const FILED_RETURN_MECHANICS: Record, + 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/gstr2b-flow.ts b/src/connectors/gst/gstr2b-flow.ts index 21b96a47..e251aace 100644 --- a/src/connectors/gst/gstr2b-flow.ts +++ b/src/connectors/gst/gstr2b-flow.ts @@ -8,6 +8,7 @@ import { navigateToReturnDashboardPage } from "./filed-returns-navigator"; import { detectFiledReturnsPortalAvailabilityIssue } from "./filed-returns-portal-availability"; import { returnFromMismatchedReturnPage } from "./filed-returns-return-type-navigation"; import { findMatchingActionableFiledReturnRows } from "./filed-returns-result-rows"; +import { bindGstr2bSummaryRefusal, declinedArtifactStep } from "./filed-returns-declined-artifact"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; import { selectFiledReturnsFiltersAndSearch } from "./filed-returns-filter-form"; import { @@ -21,6 +22,7 @@ import { hasGstr2bLoginEvidence, isGstr2bAuthRoute, isGstr2bSummaryPage, + isGstr2bSummaryRoute, readDocumentText, returnFromMismatchedGstr2bSummary, verifyVisibleGstr2bPeriod, @@ -32,6 +34,40 @@ import { isReturnDashboardStillRendering, selectGstr2bReturnDashboardFiltersAndSearch, } from "./gstr2b-dashboard-filters"; +import { isGstr2bNotGeneratedText } from "./filed-returns-post-click-blocked-state"; + +/** + * `null` when the visible page is the requested period, otherwise the step that leaves it. + * + * The summary route renders whichever period it last loaded, so both callers must confirm the + * header before trusting anything on the page -- one to record a refusal, the other to click a + * download. Failing closed is the point: an unreadable header is "could not determine". + * + * `requireVisibleEvidence` is the difference between them, and it is the refusal that sets it: + * see `verifyVisibleGstr2bPeriod`. + */ +function leaveUnlessVisiblePeriodMatches( + documentRef: Document, + normalisedText: string, + scope: FiledReturnsDownloadScope, + scopeId: string, + safeSignals: readonly string[], + requireVisibleEvidence = false, +): PortalFlowStepResult | null { + const periodGuard = verifyVisibleGstr2bPeriod( + documentRef, + normalisedText, + scope, + requireVisibleEvidence, + ); + if (!periodGuard) return null; + return ( + returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ + ...safeSignals, + ...periodGuard.safeSignals, + ]) ?? periodGuard + ); +} const FILED_RETURNS_ROUTE = /\/returns\/auth\/efiledReturns\/?$/i; @@ -73,6 +109,33 @@ export async function runGstr2bDownloadStep( }; } + // Recognised here, during observation, rather than after a click. The portal renders this panel + // instead of the download control, so the flow would otherwise wait out its whole step budget + // for a control that is never coming, then stop the fiscal-year run on a period that cannot + // produce an artifact. A blocked step ends the wait and lets the period be recorded as absent. + // + // Bound to the visible period, because this panel keeps rendering the period it was last loaded + // for. Unbound, one period's refusal answered for every later period in a fiscal-year run, which + // recorded eleven months the run never navigated to. The guard fails closed: an unreadable + // period is "could not determine", never "matches". + if (isGstr2bSummaryRoute(documentRef) && isGstr2bNotGeneratedText(normalised)) { + const binding = bindGstr2bSummaryRefusal(documentRef, normalised, scope); + if (!binding.bound) { + // A stale panel is not just refused here, it is navigated away from: the run needs the + // period it actually asked for, and waiting on this page produces nothing. + const mismatchSignals = [...safeSignals, ...binding.mismatch.safeSignals]; + return ( + returnFromMismatchedGstr2bSummary(documentRef, scopeId, mismatchSignals) ?? { + ...binding.mismatch, + safeSignals: mismatchSignals, + } + ); + } + return declinedArtifactStep(binding.bound, { + safeSignals: [...safeSignals, "gstr2b-summary-route"], + }); + } + const mismatchedReturnNavigation = returnFromMismatchedReturnPage( documentRef, scope, @@ -81,15 +144,14 @@ export async function runGstr2bDownloadStep( if (mismatchedReturnNavigation) return mismatchedReturnNavigation; if (isGstr2bSummaryPage(documentRef, normalised)) { - const periodGuard = verifyVisibleGstr2bPeriod(documentRef, normalised, scope); - if (periodGuard) { - const recovery = returnFromMismatchedGstr2bSummary(documentRef, scopeId, [ - ...safeSignals, - ...periodGuard.safeSignals, - ]); - if (recovery) return recovery; - return periodGuard; - } + const leaving = leaveUnlessVisiblePeriodMatches( + documentRef, + normalised, + scope, + scopeId, + safeSignals, + ); + if (leaving) return leaving; return { connectorId: "gst", scopeId, diff --git a/src/connectors/gst/gstr2b-summary.ts b/src/connectors/gst/gstr2b-summary.ts index 8f4fb9e7..035aedfd 100644 --- a/src/connectors/gst/gstr2b-summary.ts +++ b/src/connectors/gst/gstr2b-summary.ts @@ -13,6 +13,12 @@ import { filedReturnScopeId } from "./filed-returns-return-descriptors"; const GSTR2B_SUMMARY_ROUTE = /\/gstr2b\/auth\/gstr2b\/summary\/?$/i; const GSTR2B_AUTH_ROUTE = /\/gstr2b\/auth(?:\/|$)/i; +// The summary route alone. `isGstr2bSummaryPage` additionally requires the download controls, so +// it cannot identify the variant of this page where the portal renders a refusal in their place. +export function isGstr2bSummaryRoute(documentRef: Document): boolean { + return GSTR2B_SUMMARY_ROUTE.test(documentRef.defaultView?.location.pathname ?? ""); +} + export function isGstr2bSummaryPage(documentRef: Document, normalisedText: string): boolean { const pathname = documentRef.defaultView?.location.pathname ?? ""; return ( @@ -53,10 +59,19 @@ export function verifyVisibleGstr2bSummaryScope( return verifyVisibleGstr2bPeriod(documentRef, normalised, scope); } +/** + * `null` when this page is the requested period, otherwise the mismatch that rejects it. + * + * `requireVisibleEvidence` decides whether the page's own inline configuration may stand in for + * the identity a reader can see. A download click may rely on it, because the file that follows + * is correlated to this target before the target counts as complete. A refusal may not: it + * resolves the target outright, so the visible header is the only evidence there will ever be. + */ export function verifyVisibleGstr2bPeriod( documentRef: Document, normalisedText: string, scope: FiledReturnsDownloadScope, + requireVisibleEvidence = false, ): PortalDownloadTriggerResult | null { const serverScope = extractGstr2bServerScope(documentRef); const visiblePeriod = extractGstr2bLabelValue(normalisedText, "return period"); @@ -80,16 +95,13 @@ export function verifyVisibleGstr2bPeriod( return gstr2bPeriodMismatch(serverScope ? ["gstr2b-server-visible-period-conflict"] : []); } - if (hasCompleteLabelledEvidence) return null; + // Whole-page month/year matches are not target evidence: generated-on text and table content + // can mention another period. Only labels or the portal statement heading qualify as visible. + if (hasCompleteLabelledEvidence || statementScope) return null; - if (serverScope) return null; + if (serverScope && !requireVisibleEvidence) return null; - if (!statementScope) { - // Whole-page month/year matches are not target evidence: generated-on text and table - // content can mention another period. Only labels or the portal statement heading qualify. - return gstr2bPeriodMismatch(["gstr2b-labelled-period-evidence-missing"]); - } - return null; + return gstr2bPeriodMismatch(["gstr2b-labelled-period-evidence-missing"]); function gstr2bPeriodMismatch(extraSignals: string[]): PortalDownloadTriggerResult { return { diff --git a/src/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/connectors/gst/portal-artifact-endpoints.ts b/src/connectors/gst/portal-artifact-endpoints.ts index 41d93a05..6d6e2c0f 100644 --- a/src/connectors/gst/portal-artifact-endpoints.ts +++ b/src/connectors/gst/portal-artifact-endpoints.ts @@ -1,3 +1,9 @@ +import { + getClickableElements, + isActionablePortalControl, + normaliseText, +} from "./filed-returns-dom"; + export const GSTR2B_ORIGIN = "https://gstr2b.gst.gov.in"; export const GSTR2B_JSON_PATH = "/gstr2b/auth/api/gstr2b/getjson"; export const GSTR2B_SUMMARY_PATH = "/gstr2b/auth/gstr2b/summary"; @@ -19,13 +25,59 @@ export const GSTR1_SUMMARY_PATH = "/returns/auth/gstr1/gstr1sum"; export const GSTR1_DETAIL_PATH = "/returns/auth/gstr1"; export const GSTR1_SUMMARY_PREFLIGHT_PATH = "/returns/auth/api/gstr1/summary"; +export interface PageGeneratedArtifactSurface { + path: string; + controlText: string; +} + +// Where each GSTR-1 artifact can be acquired, and what the portal calls the control there. +// +// A filed GSTR-1 PDF can be exposed on more than one supported surface. Listing the labels here +// keeps route matching and control resolution on one shared descriptor. export const GSTR1_PAGE_GENERATED_ARTIFACTS: Record< Gstr2bPageGeneratedArtifact, - { controlText: string; expectedMime: string } + { surfaces: readonly PageGeneratedArtifactSurface[]; expectedMime: string } > = { - PDF: { controlText: "DOWNLOAD SUMMARY (PDF)", expectedMime: "application/pdf" }, + PDF: { + surfaces: [ + { path: GSTR1_SUMMARY_PATH, controlText: "DOWNLOAD SUMMARY (PDF)" }, + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD FILED (PDF)" }, + ], + expectedMime: "application/pdf", + }, EXCEL: { - controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)", + surfaces: [ + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)" }, + ], expectedMime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, }; + +/** + * The controls on this page that a descriptor's `controlText` names, and that can be clicked. + * + * A leaf clickable whose text carries the label -- not any element mentioning it. Whether a page + * offers an artifact is a question about controls, and answering it from page text lets decoy or + * non-actionable copy stand in for a button. It lives beside the descriptor so that the label and + * the search for it cannot drift apart. + */ +export function findPageArtifactControls( + documentRef: Document, + canonicalLabel: string, +): HTMLElement[] { + const normalisedLabel = normaliseText(canonicalLabel); + return getClickableElements(documentRef).filter( + (element) => + getClickableElements(element).length === 0 && + isActionablePortalControl(element) && + normaliseText(element.textContent || "").includes(normalisedLabel), + ); +} + +/** Whether the filed GSTR-1 detail route is offering the filed PDF. */ +export function offersFiledGstr1DetailPdf(documentRef: Document): boolean { + const surface = GSTR1_PAGE_GENERATED_ARTIFACTS.PDF.surfaces.find( + (candidate) => candidate.path === GSTR1_DETAIL_PATH, + ); + return surface ? findPageArtifactControls(documentRef, surface.controlText).length > 0 : false; +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 8d7c884d..45a6c568 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -76,6 +76,7 @@ export { rememberGstTabIfSupported, sendMessageToTabWithInjection, } from "../background/gst-tab-context"; +import { backgroundFailureFingerprint } from "../background/background-failure-fingerprint"; const OFFICIAL_URL = "https://pack.complyeaze.com"; @@ -159,14 +160,22 @@ export default defineBackground(() => { void handleMessage(message, sender) .then((response) => sendResponse(response)) - .catch(() => + .catch((error: unknown) => { + // The fingerprint, not the error. A handler that fails without saying why costs a build + // and a live run to locate, so the reason is named rather than discarded -- but an error + // raised by storage, scripting, or downloads can quote a URL, a path, or a response body, + // and "the console is not persisted" is not a reason to log those. The fingerprint carries + // what a reader needs and is established to be this bundle's own. + console.error( + `Pack background handler failed for ${backgroundMessageSource(message)}: ${backgroundFailureFingerprint(error)}`, + ); sendResponse({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again.`, + safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again. (${backgroundFailureFingerprint(error)})`, safeSite: backgroundMessageHandlerSite(message), - } satisfies PackMessageResponse), - ); + } satisfies PackMessageResponse); + }); return true; }); }); diff --git a/src/entrypoints/panel/panel-surface.tsx b/src/entrypoints/panel/panel-surface.tsx index 308284bc..4d492602 100644 --- a/src/entrypoints/panel/panel-surface.tsx +++ b/src/entrypoints/panel/panel-surface.tsx @@ -241,8 +241,10 @@ export function PanelSurface({ pack }: { pack: PackPanelController }) { )} {allSupportedNeedsRecovery ? ( + // Named for its own run. Both blocks can render at once, and unlabelled their + // independent reasons read as one contradictory statement.

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

) : null} {hasRecoveryActions(summary ?? null) ? ( 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 646346e960a44d4cfcc5f394835fa237e9a458f0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 16:32:25 +0530 Subject: [PATCH 02/48] test(gst): verify declined artifact recovery and result evidence --- ...-supported-full-fiscal-year-ledger.test.ts | 57 +++++ .../background-download-default.test.ts | 10 +- .../background-failure-fingerprint.test.ts | 62 ++++++ ...turns-download-trigger-acquisition.test.ts | 202 ++++++++++++++++++ ...led-returns-session-write-boundary.test.ts | 29 +++ ...eturns-single-period-bundle-ledger.test.ts | 23 ++ ...scal-year-declined-period-handling.test.ts | 69 ++++++ .../full-fiscal-year-ledger.test.ts | 95 ++++++++ ...scal-year-not-generated-round-trip.test.ts | 144 +++++++++++++ tests/background/gst-tab-selection.test.ts | 11 +- tests/connectors/artifact-source.test.ts | 24 ++- tests/connectors/artifact-validation.test.ts | 130 ++++++++++- .../filed-returns-declined-artifact.test.ts | 117 ++++++++++ .../filed-returns-durable-signals.test.ts | 25 ++- ...led-returns-flow-gstr1-acquisition.test.ts | 158 ++++++++++++++ ...-returns-flow-gstr2b-not-generated.test.ts | 168 +++++++++++++++ ...d-returns-post-click-blocked-state.test.ts | 123 +++++++++++ ...ed-returns-target-status-behaviour.test.ts | 71 ++++++ tests/docs/public-scope-copy.test.ts | 35 +-- tests/popup/target-evidence.test.tsx | 13 ++ 20 files changed, 1544 insertions(+), 22 deletions(-) create mode 100644 tests/background/background-failure-fingerprint.test.ts create mode 100644 tests/background/full-fiscal-year-declined-period-handling.test.ts create mode 100644 tests/background/full-fiscal-year-not-generated-round-trip.test.ts create mode 100644 tests/connectors/filed-returns-declined-artifact.test.ts create mode 100644 tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts create mode 100644 tests/connectors/filed-returns-post-click-blocked-state.test.ts create mode 100644 tests/connectors/filed-returns-target-status-behaviour.test.ts diff --git a/tests/background/all-supported-full-fiscal-year-ledger.test.ts b/tests/background/all-supported-full-fiscal-year-ledger.test.ts index 90f31f2b..963ba46a 100644 --- a/tests/background/all-supported-full-fiscal-year-ledger.test.ts +++ b/tests/background/all-supported-full-fiscal-year-ledger.test.ts @@ -6,6 +6,8 @@ import { canonicalDurableTargetStatus } from "../../src/connectors/gst/filed-ret import { allSupportedExplicitRetryTarget, createAllSupportedFullFiscalYearLedger, + markAllSupportedFullFiscalYearTargetRunning, + markAllSupportedFullFiscalYearTargetTerminal, createAllSupportedFullFiscalYearTargetPlan, } from "../../src/background/filed-returns-all-supported-full-fiscal-year-ledger"; import { @@ -610,3 +612,58 @@ describe("all-supported full-fiscal-year ledger", () => { ); }); }); + +describe("a period the portal declined to generate, in an all-returns year", () => { + // The single-return fiscal-year path reported this correctly from the day the status existed, + // because its status-to-outcome mapping is an exhaustive record that fails to compile when a + // status is missing. The all-returns path kept two hand-written copies ending in a + // `needs-review` default, so the same period read as needing a person in one run type and as + // resolved in the other -- and a run of everything stopped on periods that could never change. + it("reports it as not generated, not as needing review", () => { + let ledger = createLedger(); + const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); + if (!target) throw new Error("expected a GSTR-2B target in the all-returns plan"); + + ledger = markAllSupportedFullFiscalYearTargetRunning(ledger, target.targetId, NOW); + ledger = markAllSupportedFullFiscalYearTargetTerminal( + ledger, + target.targetId, + "not-generated", + { + connectorId: "gst", + scopeId: "gst-gstr2b-private-v0", + state: "blocked", + safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeMessage: "x", + } as never, + NOW, + ); + + const summary = toAllSupportedFullFiscalYearSummary(ledger); + const evidence = summary.targetEvidence.find((row) => row.targetId === target.targetId); + + expect(evidence?.outcome).toBe("not-generated"); + expect(evidence?.outcome).not.toBe("needs-review"); + }); + + it("rejects a not-generated signal on a non-GSTR-2B stored target", () => { + const ledger = createLedger(); + const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-1"); + if (!target) throw new Error("expected a GSTR-1 target in the all-returns plan"); + + const invalid = { + ...ledger, + targets: ledger.targets.map((candidate) => + candidate.targetId === target.targetId + ? { + ...candidate, + safeSignals: ["filed-gstr2b-not-generated"], + status: "not-generated" as const, + } + : candidate, + ), + }; + + expect(isAllSupportedFullFiscalYearLedger(invalid)).toBe(false); + }); +}); diff --git a/tests/background/background-download-default.test.ts b/tests/background/background-download-default.test.ts index 4a7e8d54..34c8bfc4 100644 --- a/tests/background/background-download-default.test.ts +++ b/tests/background/background-download-default.test.ts @@ -827,15 +827,21 @@ describe("background filed returns download defaults", () => { const response = await sendBackgroundMessage({ type: "PACK_GET_LAST_MANIFEST" }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: "Pack stopped while handling the local manifest request. Try the action again.", safeSite: "background-message-handler:last-manifest", }); if (response.ok) throw new Error("expected a safe background failure"); + // The reply names where it failed and appends a fingerprint of the throw. The fingerprint is + // the error class and the innermost symbol from Pack's own bundle -- never the error message, + // which is what could carry a portal URL or a local path. + expect(response.safeMessage).toContain( + "Pack stopped while handling the local manifest request. Try the action again.", + ); expect(response.safeMessage).not.toContain("portal URL"); expect(response.safeMessage).not.toContain("local path"); + expect(response.safeMessage).not.toContain("must not escape"); }); it("persists and returns a terminal GSTR-2B mismatch summary to the popup", async () => { diff --git a/tests/background/background-failure-fingerprint.test.ts b/tests/background/background-failure-fingerprint.test.ts new file mode 100644 index 00000000..18e52264 --- /dev/null +++ b/tests/background/background-failure-fingerprint.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { backgroundFailureFingerprint } from "../../src/background/background-failure-fingerprint"; + +// Frames as the service worker actually produces them: every line of a background stack points at +// a file served from the extension's own origin. A frame pointing anywhere else did not come from +// this bundle, and is the case the fingerprint must not repeat. +const BUNDLE = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/background.js"; + +describe("background failure fingerprint", () => { + it("names the error class and the innermost symbol", () => { + const error = new TypeError("boom"); + error.stack = `TypeError: boom\n at parseDurableSummary (${BUNDLE}:1:1)\n at next (${BUNDLE}:2:1)`; + expect(backgroundFailureFingerprint(error)).toBe("TypeError at parseDurableSummary"); + }); + + it("never repeats the error message", () => { + const error = new Error("GSTIN 00AAAAA0000A1Z0 rejected at https://portal.example/x"); + error.stack = `${error.message}\n at readLedger (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error at readLedger"); + }); + + it("cannot be smuggled through by a thrown non-Error", () => { + expect(backgroundFailureFingerprint("GSTIN 00AAAAA0000A1Z0")).toBe("NonError"); + expect(backgroundFailureFingerprint({ name: "x", stack: "at leak" })).toBe("NonError"); + }); + + it("takes no symbol from a frame outside this bundle", () => { + // The earlier filter deleted punctuation, so this frame came back as a plausible-looking + // symbol -- laundered into safety by removing exactly the characters that exposed it. Origin + // is what makes a symbol ours; a character class never could. + const error = new Error("x"); + error.stack = `Error: x\n at https://www.gst.gov.in/returns/auth/gstr1 (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error"); + expect(fingerprint).not.toContain("gst"); + }); + + it("takes no symbol from an anonymous frame", () => { + const error = new Error("x"); + error.stack = `Error: x\n at ${BUNDLE}:1:1`; + expect(backgroundFailureFingerprint(error)).toBe("Error"); + }); + + it("refuses a name that is not shaped like an error class", () => { + // Every error class in this bundle ends in `Error`, and so do the platform's. A name that does + // not degrades to `Error` rather than being edited until it passes. + const error = new Error("x"); + error.name = "GSTIN00AAAAA0000A1Z0"; + error.stack = `x\n at readLedger (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error at readLedger"); + expect(fingerprint).not.toContain("AAAAA"); + }); + + it("keeps the bundle's own error classes", () => { + const error = new Error("x"); + error.name = "XlsxSizeLimitError"; + error.stack = `x\n at writeWorkbook (${BUNDLE}:1:1)`; + expect(backgroundFailureFingerprint(error)).toBe("XlsxSizeLimitError at writeWorkbook"); + }); +}); diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 7e1d712e..d7411afc 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -94,6 +94,7 @@ import { } from "../../src/background/filed-returns-download-trigger"; import { withPersistedSinglePeriodSummary } from "../../src/background/filed-returns-single-period-summary"; import { FILED_RETURNS_RETURN_TYPES } from "../../src/connectors/gst/filed-returns-return-types"; +import { filedReturnScopeId } from "../../src/connectors/gst/filed-returns-return-descriptors"; const ARTIFACT_ACQUISITION_RETURN_TYPES = FILED_RETURNS_RETURN_TYPES; @@ -1030,3 +1031,204 @@ function acquiredJson(): PackMessageResponse { }, }; } + +describe("filed GSTR-1 e-invoice Excel with no details to download", () => { + // The portal answers this request with an information dialog when the taxpayer reports no + // e-invoices. The content script has always recognised it and the ledger has always known how to + // record the artifact as unavailable -- but nothing sent the message between them, so the + // recognition never ran and the run stalled offering a retry that cannot succeed. + // + // The message contract was tested; that it is ever sent was not. This pins the send, on the + // path that matters: the control was armed and clicked, and the click produced no download. + function messagingDeps(postClick: PackMessageResponse) { + return vi.fn(async (_tabId: number, message: { type: string }) => + message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" + ? postClick + : ({ + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse), + ); + } + + const noDetailsStep = { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId("GSTR-1"), + state: "blocked", + safeSignals: ["filed-gstr1-excel-no-details-available"], + safeMessage: "The GST Portal reported that no e-invoice details are available.", + }, + } as PackMessageResponse; + + function armDefinitiveNoActionFailure() { + captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ + ok: false as const, + reason: "control-not-found", + safeSignals: [] as string[], + } as never); + } + + it("asks the page why, and adopts the portal's no-details answer", async () => { + armDefinitiveNoActionFailure(); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(sendMessageToTabWithInjection).toHaveBeenCalledWith( + 17, + expect.objectContaining({ type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" }), + ); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["filed-gstr1-excel-no-details-available"]), + // Still blocked. This records an absence; it never turns a click into a download. + state: "blocked", + }, + }); + }); + + it("persists the declined terminal answer before clearing its acquisition checkpoint", async () => { + armDefinitiveNoActionFailure(); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + const persistedBefore = summaryStorage.set.mock.calls.length; + const clearedBefore = captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length; + + await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: { completion: "completion" } }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(summaryStorage.set).toHaveBeenCalledWith({ + completion: expect.objectContaining({ + completedPeriods: ["April"], + status: "complete", + }), + }); + expect(captureMocks.clearArtifactAcquisitionCheckpoint).toHaveBeenCalled(); + expect(summaryStorage.set.mock.invocationCallOrder[persistedBefore]).toBeLessThan( + captureMocks.clearArtifactAcquisitionCheckpoint.mock.invocationCallOrder[clearedBefore]!, + ); + }); + + it("retains an uncertain acquisition instead of adopting a later decline", async () => { + captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ + ok: false as const, + reason: "generation-timeout", + safeSignals: [] as string[], + } as never); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + const persistedBefore = summaryStorage.set.mock.calls.length; + const clearedBefore = captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: { completion: "completion" } }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(sendMessageToTabWithInjection).not.toHaveBeenCalledWith( + 17, + expect.objectContaining({ type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" }), + ); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["artifact-generation-timeout"]), + state: "blocked", + }, + }); + if (!response.ok || !("flowStep" in response)) throw new Error("Expected flow step."); + expect(response.flowStep.safeSignals).not.toContain("filed-gstr1-excel-no-details-available"); + expect(summaryStorage.set.mock.calls.length).toBe(persistedBefore); + expect(captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length).toBe(clearedBefore); + expect(captureMocks.persistArtifactAcquisitionIntent).toHaveBeenCalled(); + }); + + it("keeps the original failure when the page cannot be asked at all", async () => { + // This runs after an acquisition has already failed and can only refine that failure. A tab + // that has closed, navigated, or refuses injection means the failure cannot be refined -- not + // that a new one happened. Throwing here would replace a specific, actionable reason with the + // generic background error and lose the terminal summary with it. + armDefinitiveNoActionFailure(); + const sendMessageToTabWithInjection = vi.fn( + async (_tabId: number, message: { type: string }) => { + if (message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3") { + throw new Error("Could not establish connection. Receiving end does not exist."); + } + return { + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse; + }, + ); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "artifact-acquisition-failed", + "artifact-control-not-found", + ]), + state: "blocked", + }, + }); + }); + + it("keeps the original failure when the page reports no recognised block", async () => { + armDefinitiveNoActionFailure(); + const sendMessageToTabWithInjection = messagingDeps({ + ok: true, + flowStep: { + connectorId: "gst", + scopeId: "filed-returns:gstr-1", + state: "candidate-not-found", + safeSignals: ["filed-return-post-click-blocked-state-not-found"], + safeMessage: "Pack did not find a recognized post-click portal block.", + }, + } as PackMessageResponse); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["artifact-acquisition-failed"]), + state: "blocked", + }, + }); + }); +}); diff --git a/tests/background/filed-returns-session-write-boundary.test.ts b/tests/background/filed-returns-session-write-boundary.test.ts index 299093ab..16b19656 100644 --- a/tests/background/filed-returns-session-write-boundary.test.ts +++ b/tests/background/filed-returns-session-write-boundary.test.ts @@ -84,6 +84,35 @@ describe("filed-return session write boundary", () => { expect(JSON.stringify(storage.session[COMPLETION_KEY])).not.toContain("account-specific"); }); + it("does not complete a GSTR-2B scope from a GSTR-1 Excel decline signal", async () => { + const scope = { + artifactType: "PDF" as const, + financialYear: "2026-27", + period: "April", + returnType: "GSTR-2B" as const, + }; + + const response = await withPersistedSinglePeriodSummary( + scope, + { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnsScopeId(scope.returnType), + state: "blocked", + safeSignals: ["filed-gstr1-excel-no-details-available"], + safeMessage: "Synthetic incompatible decline.", + }, + }, + deps, + true, + ); + + expect(response).toMatchObject({ + flowSummary: { completedPeriods: [], status: "blocked" }, + }); + }); + it("removes stale completion state when a summary contains a non-canonical signal", async () => { storage.session[COMPLETION_KEY] = singlePeriodSummary(); 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..a02a70e3 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,29 @@ 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("refuses a GSTR-2B decline on a GSTR-1 bundle", () => { + 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).toBeNull(); + }); + 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-declined-period-handling.test.ts b/tests/background/full-fiscal-year-declined-period-handling.test.ts new file mode 100644 index 00000000..c02490fb --- /dev/null +++ b/tests/background/full-fiscal-year-declined-period-handling.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { FILED_RETURNS_MONTHS } from "../../src/connectors/gst/filed-returns-scope"; +import { createFullFiscalYearLedger } from "../../src/background/filed-returns-full-fiscal-year-ledger"; +import { markFullFiscalYearRestagingRequired } from "../../src/background/filed-returns-full-fiscal-year-staging"; +import { isFullFiscalYearLedger } from "../../src/background/filed-returns-full-fiscal-year-validation"; +import { canonicalDurableTargetStatus } from "../../src/connectors/gst/filed-returns-durable-status"; +import { RECOVERY_SCOPE } from "./full-year-completion-fixtures.test-helpers"; + +// A period the portal declined to draft is an answer, not an outcome still owed. Five separate +// places asked "is there a file to expect here?" by spelling it `status === "not-filed"`, so each +// one silently answered "no, keep working" for every declined period. +function ledgerWith(statuses: Partial>) { + const base = createFullFiscalYearLedger( + RECOVERY_SCOPE, + new Date("2026-08-24T00:00:00.000Z"), + FILED_RETURNS_MONTHS, + ); + return { + ...base, + targets: base.targets.map((target) => { + const override = statuses[target.period]; + if (!override) return target; + const status = override.status as (typeof target)["status"]; + const safeSignals = override.safeSignals ?? []; + return { + ...target, + status, + attempts: 1, + ...canonicalDurableTargetStatus(target, status, safeSignals), + }; + }), + }; +} + +describe("restaging a fiscal year", () => { + it("leaves a declined period alone, as it already does a not-filed one", () => { + // Neither staged a file, so neither has anything to restage. Resetting a declined period to + // `blocked` sends the run back to a period the portal has already answered. + const ledger = ledgerWith({ + April: { status: "not-filed", safeSignals: ["filed-return-positively-not-filed"] }, + May: { status: "not-generated", safeSignals: ["filed-gstr2b-not-generated"] }, + }); + + const restaged = markFullFiscalYearRestagingRequired(ledger, new Date("2026-08-26T00:00:00Z")); + const byPeriod = new Map(restaged.targets.map((target) => [target.period, target.status])); + + expect(byPeriod.get("April")).toBe("not-filed"); + expect(byPeriod.get("May")).toBe("not-generated"); + }); +}); + +describe("reading a stored fiscal-year ledger back", () => { + it("refuses a declined period that carries no evidence it was declined", () => { + // A status is a claim. The same rule already rejected a `not-filed` record with no portal + // signal behind it; a `not-generated` record could assert the portal declined a period that + // nothing ever established. + const ledger = ledgerWith({ April: { status: "not-generated", safeSignals: [] } }); + + expect(isFullFiscalYearLedger(ledger)).toBe(false); + }); + + it("refuses a declined period with a return-type-incompatible signal", () => { + const ledger = ledgerWith({ + April: { status: "not-generated", safeSignals: ["filed-gstr2b-not-generated"] }, + }); + + expect(isFullFiscalYearLedger(ledger)).toBe(false); + }); +}); diff --git a/tests/background/full-fiscal-year-ledger.test.ts b/tests/background/full-fiscal-year-ledger.test.ts index e154030a..ca526756 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,95 @@ 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.", + }, + "GSTR-2B", + ), + ).toBe("not-generated"); + }); + + it("does not resolve the GSTR-2B signal for another return type", () => { + expect( + targetStatusFromFlowStep( + { + connectorId: "gst", + scopeId: "gst-filed-returns-gstr1-pdf-private-v0", + state: "blocked", + safeSignals: ["filed-gstr2b-not-generated"], + safeMessage: "Synthetic declined artifact.", + }, + "GSTR-1", + ), + ).toBe("blocked"); + }); + + 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..66a07e1b --- /dev/null +++ b/tests/background/full-fiscal-year-not-generated-round-trip.test.ts @@ -0,0 +1,144 @@ +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 { declinedArtifactSafeMessage } from "../../src/connectors/gst/filed-returns-declined-artifact"; +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", +}; + +// A synthetic terminal refusal establishes that this target has no artifact to stage. It is a +// resolved absence, not a transient download failure. +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: declinedArtifactSafeMessage("filed-gstr2b-not-generated"), + }; +} + +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); + + // The planner must not count a resolved absence as an artifact it failed to stage. + 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/background/gst-tab-selection.test.ts b/tests/background/gst-tab-selection.test.ts index 21b9a2f4..6773a846 100644 --- a/tests/background/gst-tab-selection.test.ts +++ b/tests/background/gst-tab-selection.test.ts @@ -449,14 +449,19 @@ describe("Pack GST tab selection", () => { listener?.({ type: "PACK_GET_CONTEXT" }, { id: "pack-test-extension" }, resolve); }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: - "Pack stopped while handling the current GST Portal state. Try the action again.", safeSite: "background-message-handler:gst-context", }); + const { safeMessage } = response as { safeMessage: string }; + expect(safeMessage).toContain( + "Pack stopped while handling the current GST Portal state. Try the action again.", + ); + // The rejected detail is the error's message, and it never reaches the reply. The appended + // fingerprint carries only the error class and a symbol from Pack's own bundle. expect(JSON.stringify(response)).not.toContain("sensitive portal detail"); + expect(safeMessage).not.toContain("must not be exposed"); }); it("infers a GSTR-2B observation from the active summary route instead of returning stale wrong-page", async () => { diff --git a/tests/connectors/artifact-source.test.ts b/tests/connectors/artifact-source.test.ts index d3c648e2..274dc5f7 100644 --- a/tests/connectors/artifact-source.test.ts +++ b/tests/connectors/artifact-source.test.ts @@ -432,7 +432,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -468,7 +468,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -483,7 +483,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-ambiguous"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -520,6 +520,24 @@ describe("GSTR-1 artifact acquisition", () => { expect(artifactControl?.getAttribute("data-pack-artifact-request")).toBe(request.requestId); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(1); }); + + it("accepts the supported trailing-slash detail route for a direct filed PDF", async () => { + const { documentRef } = gstr1Page( + gstr1Json("042026"), + "https://return.gst.gov.in/returns/auth/gstr1/", + "DOWNLOAD FILED (PDF)", + ); + + await expect(acquireFiledReturnArtifact(documentRef, request)).resolves.toMatchObject({ + ok: true, + state: "ready", + }); + expect( + documentRef + .querySelector("[data-testid='artifact-control']") + ?.getAttribute("data-pack-artifact-request"), + ).toBe(request.requestId); + }); }); function validJson() { diff --git a/tests/connectors/artifact-validation.test.ts b/tests/connectors/artifact-validation.test.ts index 93332c18..cfb00d6e 100644 --- a/tests/connectors/artifact-validation.test.ts +++ b/tests/connectors/artifact-validation.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + MAX_ARTIFACT_BYTES, + describeJsonArtifactRejection, filedReturnsJsonDocumentContract, validateArtifactBytes, } from "../../src/connectors/gst/artifact-validation"; +import { ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS } from "../../src/connectors/gst/filed-returns-acquisition-diagnostics"; +import { + PORTAL_BLOB_SHIM_SUPPRESSION_METHODS, + RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS, + isDurableFiledReturnsSignal, +} from "../../src/connectors/gst/filed-returns-durable-signals"; const encoder = new TextEncoder(); @@ -94,3 +102,123 @@ describe("validateArtifactBytes", () => { }); }); }); + +describe("compact filed-return summary envelopes", () => { + // A compact envelope that satisfies the GSTR-1 contract remains valid; a size floor alone + // cannot distinguish it from truncated content. + const compactGstr1Envelope = encoder.encode(JSON.stringify({ data: { ret_period: "042025" } })); + + it("accepts a valid summary envelope that is smaller than a hundred bytes", () => { + expect(compactGstr1Envelope.byteLength).toBeLessThan(100); + expect(validateArtifactBytes(compactGstr1Envelope, "JSON", "042025", "GSTR-1")).toEqual({ + ok: true, + mimeType: "application/json", + }); + }); + + it("keeps the compact-envelope exception scoped to GSTR-1", () => { + const compactGstr2bEnvelope = encoder.encode(JSON.stringify({ data: { rtnprd: "042025" } })); + expect(compactGstr2bEnvelope.byteLength).toBeLessThan(100); + expect(validateArtifactBytes(compactGstr2bEnvelope, "JSON", "042025", "GSTR-2B")).toEqual({ + ok: false, + reason: "unexpected-content", + }); + }); + + // What the floor was standing in for, each caught by the contract instead and caught better. + it("still refuses compact bodies the contract rejects", () => { + const cases: Array<[string, Uint8Array, string]> = [ + ["not JSON at all", encoder.encode("error"), "unexpected-content"], + ["no envelope", encoder.encode(JSON.stringify({ status: 1 })), "unexpected-content"], + [ + "no period field", + encoder.encode(JSON.stringify({ data: { other: "x" } })), + "unexpected-content", + ], + [ + "another period", + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + "target-period-mismatch", + ], + ]; + for (const [label, bytes, reason] of cases) { + expect(bytes.byteLength, label).toBeLessThan(100); + expect(validateArtifactBytes(bytes, "JSON", "042025", "GSTR-1"), label).toEqual({ + ok: false, + reason, + }); + } + }); + + it("still refuses an empty body", () => { + expect(validateArtifactBytes(new Uint8Array(), "JSON", "042025", "GSTR-1")).toEqual({ + ok: false, + reason: "empty", + }); + }); +}); + +describe("acquisition diagnostics are persistable", () => { + // The defect this pins: a diagnostic signal that is emitted but not registered rejects the + // entire durable signal array, so the run halts on non-canonical recovery metadata instead of + // recording the refusal the signal was there to explain. Emitting a new one without registering + // it is worse than not emitting it at all. + it("registers every acquisition diagnostic as a durable signal", () => { + const unregistered = ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS.filter( + (signal) => !isDurableFiledReturnsSignal(signal), + ); + expect(unregistered).toEqual([]); + }); + + it("emits only registered signals when a compact body fails the contract", () => { + const bodies = [ + encoder.encode(""), + encoder.encode(JSON.stringify({ status: 1 })), + encoder.encode(JSON.stringify({ data: { other: "x" } })), + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + new Uint8Array(), + ]; + for (const bytes of bodies) { + for (const signal of describeJsonArtifactRejection(bytes, "042025", "GSTR-1")) { + expect(isDurableFiledReturnsSignal(signal), signal).toBe(true); + } + } + }); +}); + +describe("template-built signals are persistable", () => { + // These are assembled from a prefix and a variable, so a scan for signal string literals never + // sees them. Three reached production unregistered, and each one rejected the entire durable + // array it travelled in -- halting the run on non-canonical recovery metadata instead of on the + // navigation problem the signal described. + it("registers every returns-dashboard anchor failure and blob-shim suppression", () => { + const built = [ + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map( + (reason) => `returns-dashboard-anchor-${reason}`, + ), + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + ]; + expect(built.filter((signal) => !isDurableFiledReturnsSignal(signal))).toEqual([]); + }); + + it("names an oversized body without decoding or parsing it", () => { + // The cap is a processing bound, not only a verdict. Decoding and parsing a body already known + // to be too large spends exactly the work the cap refuses, on the path where the input is known + // to be unreasonable. + const oversized = new Uint8Array(MAX_ARTIFACT_BYTES + 1); + const decode = vi.spyOn(TextDecoder.prototype, "decode"); + const parse = vi.spyOn(JSON, "parse"); + try { + expect(describeJsonArtifactRejection(oversized, "052025", "GSTR-1")).toEqual([ + "json-body-oversized", + ]); + expect(decode).not.toHaveBeenCalled(); + expect(parse).not.toHaveBeenCalled(); + } finally { + decode.mockRestore(); + parse.mockRestore(); + } + }); +}); diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts new file mode 100644 index 00000000..d0c39c38 --- /dev/null +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + DECLINED_ARTIFACT_SIGNALS, + type DeclinedArtifactSignal, +} from "../../src/connectors/gst/filed-returns-acquisition-diagnostics"; +import { + REFUSAL_BINDING_SIGNALS, + bindGstr1DetailRefusal, + bindGstr2bSummaryRefusal, + declinedArtifactSafeMessage, + declinedArtifactStep, +} from "../../src/connectors/gst/filed-returns-declined-artifact"; +import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; +import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; +import { normaliseText } from "../../src/connectors/gst/filed-returns-dom"; + +const SCOPE = { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-2B", +} as const; + +function refusalPage(period: string): Document { + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+

Financial Year - 2025-26

+

Return Period - ${period}

+
GSTR-2B could not be generated by the System.
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + return documentRef; +} + +describe("a declined artifact cannot be recorded unbound", () => { + // The guarantee this module exists for, and it is a compile-time one: `declinedArtifactStep` + // takes proof as its first argument, and the only values of that type come from a binder here. + // Four emitters learned the check one reported defect at a time; this is what stops a fifth + // from not learning it. `tsc --noEmit` fails if either line below stops being an error. + it("will not type-check a result built from a forged binding", () => { + // @ts-expect-error an object literal does not satisfy the branded binding + const forged: Parameters[0] = { safeSignals: [] }; + expect(forged).toBeDefined(); + + const bindingRequired = () => + // @ts-expect-error the proof argument cannot be omitted + declinedArtifactStep({ + signal: "filed-gstr2b-not-generated", + returnType: "GSTR-2B", + safeSignals: [], + }); + expect(bindingRequired).toBeDefined(); + }); + + it("refuses to bind a page showing another period, so no result can be built", () => { + const documentRef = refusalPage("April"); + + const binding = bindGstr2bSummaryRefusal( + documentRef, + normaliseText(documentRef.body.innerText || documentRef.body.textContent || ""), + { + ...SCOPE, + period: "May", + }, + ); + + expect(binding.bound).toBeNull(); + }); + + it("refuses an Excel-only GSTR-1 decline for a PDF target", () => { + const binding = bindGstr1DetailRefusal(refusalPage("April"), { + actionId: "synthetic-action", + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(binding.bound).toBeNull(); + }); + + it("carries what the guard established into the record", () => { + const documentRef = refusalPage("April"); + const binding = bindGstr2bSummaryRefusal( + documentRef, + normaliseText(documentRef.body.innerText || documentRef.body.textContent || ""), + SCOPE, + ); + if (!binding.bound) throw new Error("Expected the visible period to bind."); + + const step = declinedArtifactStep(binding.bound, { + safeSignals: ["gstr2b-summary-route"], + }); + + expect(step.state).toBe("blocked"); + expect(step.safeSignals).toContain("gstr2b-visible-period-verified"); + expect(step.safeSignals).toContain("filed-gstr2b-not-generated"); + // A refusal is terminal, so its signals are persisted. One token the allowlist has never been + // told about rejects the whole array, which is why the binding signals register by derivation. + expect(parseDurableFiledReturnsSignals(step.safeSignals)).not.toBeNull(); + }); +}); + +describe("the copy a declined artifact carries", () => { + it.each(DECLINED_ARTIFACT_SIGNALS)("gives %s its own wording", (signal) => { + expect(declinedArtifactSafeMessage(signal as DeclinedArtifactSignal)).toMatch(/GST Portal/u); + }); + + it("registers every binding signal for durable storage", () => { + expect(parseDurableFiledReturnsSignals([...REFUSAL_BINDING_SIGNALS])).not.toBeNull(); + }); +}); diff --git a/tests/connectors/filed-returns-durable-signals.test.ts b/tests/connectors/filed-returns-durable-signals.test.ts index f84741ae..f777277e 100644 --- a/tests/connectors/filed-returns-durable-signals.test.ts +++ b/tests/connectors/filed-returns-durable-signals.test.ts @@ -1,5 +1,5 @@ import { FILED_RETURNS_WORKBOOK_ABSENCE_OUTCOMES } from "../../src/connectors/gst/offscreen-blob-url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { durableFiledReturnsSignalRejectionReason, isDurableFiledReturnsSignal, @@ -537,4 +537,27 @@ describe("filed-return durable signal contract", () => { expect(parseDurableFiledReturnsSignals([signals[0], signal])).toBeNull(); } }); + + it("never repeats a rejected token to a console sink", () => { + // This boundary exists because it cannot know what it has been handed -- a legacy or malformed + // entry read back from storage is exactly the input it refuses. Naming that entry in a log + // undoes the refusal it just made. + const sinks = [ + vi.spyOn(console, "warn").mockImplementation(() => undefined), + vi.spyOn(console, "error").mockImplementation(() => undefined), + vi.spyOn(console, "log").mockImplementation(() => undefined), + ]; + try { + const rejected = parseDurableFiledReturnsSignals([ + "gstr2b-for-00AAAAA0000A1Z0-at-https://portal.example/x", + ]); + + expect(rejected).toBeNull(); + const written = sinks.flatMap((sink) => sink.mock.calls.flat()).join(" "); + expect(written).not.toContain("00AAAAA0000A1Z0"); + expect(written).not.toContain("portal.example"); + } finally { + for (const sink of sinks) sink.mockRestore(); + } + }); }); diff --git a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts index 0df300c4..f6a4b0a0 100644 --- a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts +++ b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts @@ -468,6 +468,101 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(back).not.toHaveBeenCalled(); }); + it("still uses View Summary when the filed-PDF label is only text", async () => { + // The label appearing on the page is not the page offering the download. Skipping the real + // View Summary control because some non-actionable copy carries the words strands a target + // whose PDF was reachable: acquisition then finds no control to click and blocks it. + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+

Use DOWNLOAD FILED (PDF) once the summary has been generated.

+ +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(1); + expect(result.safeSignals).toContain("filed-gstr1-summary-view-clicked"); + }); + + it("still uses View Summary when the direct filed-PDF control is disabled", async () => { + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+ + +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(1); + }); + + it("skips View Summary when the detail route offers a filed-PDF control to click", async () => { + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+ + +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(0); + expect(result.safeSignals).not.toContain("filed-gstr1-summary-view-clicked"); + }); + it("does not leave a filed GSTR-1 summary when its visible scope is incomplete", async () => { const documentRef = createGstDocument( ` @@ -546,6 +641,69 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(excelClicked).toBe(1); }); + it("refuses the no-details dialog for a target the visible page is not about", async () => { + // The detail route does not change per period and a dialog outlives the target that raised it. + // Answering from body text alone lets one period's refusal mark another period's artifact + // unavailable, and a composite or full-year run then carries on having omitted an artifact the + // portal never declined for it. + const documentRef = createDocument(` +
+ +

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - May
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+ +
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "June", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + + it("refuses the no-details dialog when the detail header cannot be read", async () => { + // Fail closed: a page that does not name its period is "could not determine", never "matches". + const documentRef = createDocument(` +
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "May", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + it("returns from a mismatched detail page before running the requested exact period", async () => { const documentRef = createDocument(`
diff --git a/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts new file mode 100644 index 00000000..f90228e0 --- /dev/null +++ b/tests/connectors/filed-returns-flow-gstr2b-not-generated.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { runFiledReturnsDownloadStep } from "../../src/connectors/gst/filed-returns-flow"; +import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; +import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; + +// Synthetic fixture for a terminal refusal whose visible identity must bind to the requested +// target before the run may treat it as resolved. +function createGstr2bNotGeneratedDocument(period: string, financialYear = "2025-26"): Document { + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+

Financial Year - ${financialYear}

+

Return Period - ${period}

+

Generation date -

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

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+
GSTR-2B could not be generated by the System.
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + }); + + it("refuses to answer on the page's own configuration when nothing visible says so", async () => { + // The inline config is what the page asked the server for, not what a reader can see, and the + // refusal resolves the period outright -- no artifact follows to corroborate it. A download + // click may lean on this config because its file is correlated afterwards; a refusal may not. + const documentRef = createGstDocument( + ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+
GSTR-2B could not be generated by the System.
+
+ + `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + makeLayoutVisible(documentRef); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + expect(result.safeSignals).toContain("gstr2b-labelled-period-evidence-missing"); + }); +}); + +describe("recovering from a stale refusal", () => { + it("goes back to the dashboard to select the period it was asked for", async () => { + const documentRef = createGstr2bNotGeneratedDocument("April"); + const back = documentRef.createElement("button"); + back.textContent = "BACK TO DASHBOARD"; + documentRef.querySelector("main")?.append(back); + let clicked = 0; + back.addEventListener("click", () => { + clicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period: "May", + returnType: "GSTR-2B", + }); + + // Observable outcome: Pack navigates, rather than answering for a page it never loaded or + // stalling until the step budget runs out. + expect(clicked).toBe(1); + expect(result.state).toBe("clicked"); + expect(result.safeSignals).not.toContain("filed-gstr2b-not-generated"); + }); +}); + +describe("the signals a terminal step leaves behind", () => { + // A terminal step's signals are persisted. One token the allowlist has never been told about + // rejects the entire array, which blocks the target -- so every period the portal declined came + // back as "needs review" instead of as the answer it was. The transient "ready" step carried the + // same token harmlessly for months, because its signals never reach durable state. + it.each([ + ["the refusal it records", "April"], + ["the stale page it walks away from", "May"], + ])("survives storage: %s", async (_label, period) => { + const documentRef = createGstr2bNotGeneratedDocument("April"); + + const step = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF_AND_EXCEL", + financialYear: "2025-26", + period, + returnType: "GSTR-2B", + }); + + expect(parseDurableFiledReturnsSignals(step.safeSignals)).not.toBeNull(); + }); +}); 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..fc6ba785 --- /dev/null +++ b/tests/connectors/filed-returns-post-click-blocked-state.test.ts @@ -0,0 +1,123 @@ +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", () => { + // Synthetic fixture: it keeps only the structural outcome, visible identity, and decoy control + // needed to prove that a bound absence is terminal. It is not captured portal markup. + const errorPanel = ` +
+

GSTR-2B- AUTO-DRAFTED ITC STATEMENT

+

Financial Year - 2025-26

+

Return Period - April

+
GSTR-2B could not be generated by the System.
+ +
+ `; + + 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", + ); + }); + + // A stale-looking panel must not answer for a target unless its visible identity also binds to + // that target. + 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 on the page's own configuration when nothing visible says so", () => { + // Same rule as the observation path: a refusal resolves the target outright and no artifact + // follows to corroborate it, so the inline config cannot stand in for the visible header. + const documentRef = createGstDocument( + ` +
+
GSTR-2B could not be generated by the System.
+
+ + `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])).toBeNull(); + }); + + it("refuses to answer when nothing visible names the period at all", () => { + // Fail closed: an unlabelled panel is "could not determine", never "matches". + const documentRef = createGstDocument( + ` +
+
GSTR-2B could not be generated by the System.
+
+ `, + "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2b/summary", + ); + + expect(detectPostClickBlockedState(documentRef, target, [])).toBeNull(); + }); +}); diff --git a/tests/connectors/filed-returns-target-status-behaviour.test.ts b/tests/connectors/filed-returns-target-status-behaviour.test.ts new file mode 100644 index 00000000..53ed64dc --- /dev/null +++ b/tests/connectors/filed-returns-target-status-behaviour.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES, + filedReturnsTargetStatusBehaviour, + holdsFullFiscalYearTargetAnswer, + isResolvedFullFiscalYearTargetStatus, + needsExplicitFullFiscalYearRetry, + statesFullFiscalYearTargetAbsence, +} from "../../src/connectors/gst/filed-returns-contracts"; + +// What a status means was previously asked as literal comparisons in twenty-two modules, and +// nothing connected them: `not-generated` joined the union and five separate sites went on +// spelling the question `=== "not-filed"`. These are the invariants that make the table a single +// answer rather than a sixth copy of one. +describe("what a target status means", () => { + const statuses = FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES; + + it("answers every question for every member", () => { + for (const status of statuses) { + const behaviour = filedReturnsTargetStatusBehaviour(status); + expect(behaviour, status).toBeDefined(); + expect(Object.keys(behaviour).sort()).toEqual([ + "active", + "holdsAnswer", + "producedFile", + "requiredEvidenceSignal", + "resolved", + "statedAbsence", + ]); + } + }); + + it.each(statuses)("puts %s in exactly one of active, resolved, or needs-a-decision", (status) => { + const behaviour = filedReturnsTargetStatusBehaviour(status); + const buckets = [ + behaviour.active, + behaviour.resolved, + needsExplicitFullFiscalYearRetry(status), + ].filter(Boolean); + expect(buckets).toHaveLength(1); + }); + + it.each(statuses)("keeps %s consistent across the narrower questions", (status) => { + const behaviour = filedReturnsTargetStatusBehaviour(status); + // A stated absence is an answer from the portal, so it resolves the target. + if (behaviour.statedAbsence) expect(isResolvedFullFiscalYearTargetStatus(status)).toBe(true); + // A staged file is an answer too. + if (behaviour.producedFile) expect(isResolvedFullFiscalYearTargetStatus(status)).toBe(true); + // Anything resolved is an answer worth keeping; the reverse does not hold -- a manually + // observed target was answered by a person, which is work a run must not overwrite either. + if (behaviour.resolved) expect(holdsFullFiscalYearTargetAnswer(status)).toBe(true); + // An absence stages nothing, which is why restaging must leave it alone. + if (behaviour.statedAbsence) expect(behaviour.producedFile).toBe(false); + }); + + it("treats both stated absences alike, and nothing else as one", () => { + const absences = statuses.filter((status) => statesFullFiscalYearTargetAbsence(status)); + // Distinct to a reader -- one is a claim about the taxpayer, the other about the portal -- and + // the same answer to "is there a file to expect?". Every site that asked the second question + // by naming only `not-filed` was wrong. + expect([...absences].sort()).toEqual(["not-filed", "not-generated"]); + }); + + it("requires corroborating evidence for every claim that can be asserted without it", () => { + // `downloaded` is absent deliberately: its evidence is a richer predicate than a signal name. + const requiring = statuses + .filter((status) => filedReturnsTargetStatusBehaviour(status).requiredEvidenceSignal) + .sort(); + expect(requiring).toEqual(["not-filed", "not-generated"]); + }); +}); diff --git a/tests/docs/public-scope-copy.test.ts b/tests/docs/public-scope-copy.test.ts index 7428c54e..b307f5a5 100644 --- a/tests/docs/public-scope-copy.test.ts +++ b/tests/docs/public-scope-copy.test.ts @@ -72,6 +72,17 @@ const DESCRIPTION_COPY_FLOOR = [ const TEXT_FILE = /\.(ts|tsx|js|mjs|cjs|json|md|svg|html|yml|yaml)$/; +function misclassifiesGstr2b(sentence: string): boolean { + // Quoted machine signals are identifiers, not product copy. Remove only the + // token so an incorrect claim elsewhere on the same line still fails. + const prose = sentence.replace(/(["'`])filed-[a-z0-9]+(?:-[a-z0-9]+)*\1/g, ""); + if (!prose.includes("GSTR-2B") || !/\bfiled\b/i.test(prose)) return false; + if (/filed-return/i.test(prose) || /\bFiled Returns\b/.test(prose)) return false; + return !( + /auto-drafted[^.]{0,25}GSTR-2B/i.test(prose) || /GSTR-2B[^.]{0,25}\bstatement/i.test(prose) + ); +} + function trackedTextFiles(): string[] { return execFileSync("git", ["ls-files"], { cwd: rootDir, encoding: "utf8" }) .split("\n") @@ -437,6 +448,15 @@ describe("public scope copy", () => { // the rule is positive instead: where a sentence says "filed" and names // GSTR-2B, the copy must separately mark GSTR-2B as auto-drafted or as a // statement. That is what every correct sentence in this repo already does. + it.each([ + ['scope.returnType === "GSTR-2B" && signals.includes("filed-gstr2b-not-generated")', false], + ['"Filed GSTR-2B JSON"', true], + ['"filed-gstr2b-not-generated"; message = "Filed GSTR-2B JSON"', true], + ["Save filed GSTR-1 returns and auto-drafted GSTR-2B statements.", false], + ])("distinguishes copy from machine signals in %s", (sentence, rejected) => { + expect(misclassifiesGstr2b(sentence)).toBe(rejected); + }); + it("never classifies GSTR-2B as a filed return", async () => { const misclassified: string[] = []; @@ -471,18 +491,9 @@ describe("public scope copy", () => { .replace(/\n\s*\|/g, ". |") .split("."); for (const sentence of segments) { - if (!sentence.includes("GSTR-2B") || !/\bfiled\b/i.test(sentence)) continue; - // Two shapes are not prose about GSTR-2B and must not be flagged: - // a hyphenated identifier such as `filed-return-detail-type`, and the - // portal's own control name "View Filed Returns", which Pack quotes - // when it explains which page it left. - if (/filed-return/i.test(sentence)) continue; - if (/\bFiled Returns\b/.test(sentence)) continue; - - const marked = - /auto-drafted[^.]{0,25}GSTR-2B/i.test(sentence) || - /GSTR-2B[^.]{0,25}\bstatement/i.test(sentence); - if (!marked) misclassified.push(`${relativePath}\n ${sentence.trim()}`); + if (misclassifiesGstr2b(sentence)) { + misclassified.push(`${relativePath}\n ${sentence.trim()}`); + } } } diff --git a/tests/popup/target-evidence.test.tsx b/tests/popup/target-evidence.test.tsx index d6afda05..0ab7ebe5 100644 --- a/tests/popup/target-evidence.test.tsx +++ b/tests/popup/target-evidence.test.tsx @@ -79,6 +79,19 @@ describe("per-target evidence", () => { expect(markup).not.toContain("Saved"); }); + it("names a not-generated statement without counting it as a saved file", () => { + const summary = summaryWith([{ period: "April", outcome: "not-generated" }]); + summary.scope = { ...summary.scope, artifactType: "EXCEL", returnType: "GSTR-2B" }; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("April"); + expect(markup).toContain("Not generated"); + expect(markup).toContain("0 of 1 saved"); + expect(markup).not.toContain("Saved"); + expect(markup).not.toContain("Needs review"); + }); + it("renders nothing when the run carries no per-target evidence", () => { const summary = summaryWith([{ period: "April", outcome: "saved" }]); delete summary.targetEvidence; From 030b9b47c0fe72a9592b0a69eb4a9e57ad7a93e0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 17:28:36 +0530 Subject: [PATCH 03/48] fix(gst): preserve terminal refusal recovery boundaries --- .../filed-returns-download-trigger.ts | 11 ++- .../filed-returns-selected-artifacts.ts | 84 ++++++++++++++++--- ...led-returns-single-period-bundle-ledger.ts | 51 +++++++++++ .../gst/filed-returns-declined-artifact.ts | 21 ++++- 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index be5c2b7e..0919b018 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -694,9 +694,9 @@ async function triggerPageGeneratedSinglePeriodArtifact( }; } - // An uncertain acquisition can already own a browser download or a durable intent. A later - // page inspection cannot establish that no download occurred, so keep that outcome and its - // checkpoint for recovery rather than replacing it with a terminal absence. + // A later page refusal cannot rule out another side effect after an uncertain acquisition. + // Keep the original target-bound checkpoint for recovery unless acquisition itself was + // definitive; only then can an inspected refusal replace the failure. const declined = retainCheckpointForRecovery ? null : await postClickBlockedStep({ @@ -715,6 +715,9 @@ async function triggerPageGeneratedSinglePeriodArtifact( // A worker can stop after the intent is removed but before the outer flow persists this // terminal answer. Make the answer durable first; only then may `finally` remove the // recovery checkpoint that kept the failed acquisition restart-safe. + // Set this before the write. A rejected storage write must leave the existing recovery + // checkpoint intact; only a confirmed durable terminal result permits its removal. + retainCheckpointForRecovery = true; const completionKey = deps.storageKeys.completion; const persisted = completionKey ? await persistSinglePeriodSummary({ ...scope, artifactType }, declined.flowStep, { @@ -722,7 +725,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( ...(deps.now ? { now: deps.now } : {}), }) : null; - retainCheckpointForRecovery = !persisted; + if (persisted) retainCheckpointForRecovery = false; return declined; } diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index 2ecb06b2..dfff8993 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -55,6 +55,7 @@ import { persistSinglePeriodBundleArtifactRunning, persistSinglePeriodBundleArtifactStaged, persistSinglePeriodBundleArtifactUnavailable, + persistSinglePeriodBundlePeriodUnavailable, persistSinglePeriodBundleCleanupPending, persistSinglePeriodBundleZipDownloadId, persistSinglePeriodBundleZipIntent, @@ -274,6 +275,46 @@ export async function triggerSelectedArtifacts({ } if (response.flowStep.state !== "downloaded") { if (singlePeriodBundleLedger) { + const periodWideRefusal = + scope.returnType === "GSTR-2B" && + response.flowStep.safeSignals.includes("filed-gstr2b-not-generated"); + if (periodWideRefusal) { + const unavailableLedger = await persistSinglePeriodBundlePeriodUnavailable( + singlePeriodBundleLedger, + response.flowStep, + deps.now?.() ?? new Date(), + ); + if (!unavailableLedger) { + const reviewLedger = await persistSinglePeriodBundleArtifactReview( + singlePeriodBundleLedger, + artifactType, + response.flowStep, + deps.now?.() ?? new Date(), + ); + return persistAmbiguousSinglePeriodBundleResponse( + reviewLedger ?? singlePeriodBundleLedger, + deps, + response.flowStep, + ); + } + singlePeriodBundleLedger = unavailableLedger; + const bundleFlowStep = singlePeriodBundleFlowStep(unavailableLedger); + if (!bundleFlowStep) return staleSinglePeriodBundleResponse(unavailableLedger); + // Keep the bound period-wide proof alongside the per-artifact ledger result. The + // terminal summary validates the refusal itself, not merely the derived reason string. + combinedFlowStep = { + ...bundleFlowStep, + safeSignals: Array.from( + new Set([...bundleFlowStep.safeSignals, ...response.flowStep.safeSignals]), + ), + }; + lastResponse = { ...response, flowStep: combinedFlowStep }; + for (const artifact of unavailableLedger.artifacts) { + if (artifact.status === "unavailable") + completedArtifactTypes.add(artifact.artifactType); + } + continue; + } const unavailableLedger = await persistSinglePeriodBundleArtifactUnavailable( singlePeriodBundleLedger, artifactType, @@ -395,24 +436,47 @@ export async function triggerSelectedArtifacts({ ? staleSinglePeriodBundleResponse(singlePeriodBundleLedger) : response; } - if (!response.flowStep.safeSignals.includes("single-period-opfs-staged")) { - return singlePeriodBundleLedger - ? staleSinglePeriodBundleResponse(singlePeriodBundleLedger) - : response; - } if (!singlePeriodBundleLedger) return staleSinglePeriodBundleResponse(null, scope); const entryPlan = singlePeriodBundleEntryPlan(singlePeriodBundleLedger); if (!entryPlan) return staleSinglePeriodBundleResponse(singlePeriodBundleLedger); if (entryPlan.artifactTypes.length === 0) { + const terminalStep: PortalFlowStepResult = { + ...response.flowStep, + state: "blocked", + safeMessage: + "Pack recorded the selected artifacts as unavailable, so it did not create a ZIP.", + }; + let summary; + try { + summary = await persistCanonicalSinglePeriodCompletion( + artifactDeps.storageKeys.completion, + scope, + terminalStep, + deps.now?.() ?? new Date(), + ); + } catch { + return singlePeriodBundleBlockedResponse( + scope, + ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], + "Pack retained the selected-file recovery state because it could not save the terminal absence.", + true, + ); + } + if (summary) { + await clearSinglePeriodBundleLedger( + singlePeriodBundleLedger.ledgerId, + singlePeriodBundleLedger.revision, + ); + } return { ...response, - flowStep: { - ...response.flowStep, - state: "blocked", - safeMessage: `${response.flowStep.safeMessage} Pack could not create a ZIP because every selected artifact was missing.`, - }, + flowStep: terminalStep, + ...(summary ? { flowSummary: summary } : {}), }; } + if (!response.flowStep.safeSignals.includes("single-period-opfs-staged")) { + return staleSinglePeriodBundleResponse(singlePeriodBundleLedger); + } const zipCheckpointDeps = { ...artifactDeps, diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index fd4eaefa..6498acd5 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -219,6 +219,21 @@ export async function persistSinglePeriodBundleArtifactUnavailable( ); } +/** + * A bound GSTR-2B refusal answers the period, rather than one format within it. + * It is only safe to apply while no sibling has staged evidence that would + * contradict the refusal. + */ +export async function persistSinglePeriodBundlePeriodUnavailable( + expectedLedger: SinglePeriodBundleLedger, + flowStep: PortalFlowStepResult, + now = new Date(), +): Promise { + return transitionStoredLedger(expectedLedger, (ledger) => + markSinglePeriodBundlePeriodUnavailable(ledger, flowStep, now), + ); +} + export async function persistSinglePeriodBundleArtifactReview( expectedLedger: SinglePeriodBundleLedger, artifactType: FiledReturnsConcreteArtifactType, @@ -397,6 +412,37 @@ export function markSinglePeriodBundleArtifactUnavailable( return allArtifactsTerminal(updated) ? { ...updated, phase: "ready-for-zip" } : updated; } +export function markSinglePeriodBundlePeriodUnavailable( + ledger: SinglePeriodBundleLedger, + flowStep: PortalFlowStepResult, + now: Date, +): SinglePeriodBundleLedger | null { + if ( + ledger.phase !== "collecting" || + ledger.scope.returnType !== "GSTR-2B" || + !flowStep.safeSignals.includes("filed-gstr2b-not-generated") || + !ledger.artifacts.some((artifact) => artifact.status === "running") || + ledger.artifacts.some((artifact) => artifact.status === "staged") + ) { + return null; + } + const missingReason = missingArtifactReason(flowStep, ledger.scope.returnType); + if (!missingReason) return null; + const timestamp = now.toISOString(); + const updated = nextLedger(ledger, now, { + artifacts: ledger.artifacts.map((artifact) => ({ + artifactType: artifact.artifactType, + completedAt: timestamp, + missingReason, + safeSignals: ["single-period-bundle-artifact-unavailable"], + startedAt: artifact.startedAt ?? timestamp, + status: "unavailable" as const, + updatedAt: timestamp, + })), + }); + return updated ? { ...updated, phase: "ready-for-zip" } : null; +} + export function markSinglePeriodBundleArtifactReview( ledger: SinglePeriodBundleLedger, artifactType: FiledReturnsConcreteArtifactType, @@ -499,6 +545,11 @@ export function singlePeriodBundleFlowStep( : [ `filed-return-artifact-unavailable:${artifact.artifactType}`, artifact.missingReason!, + ...(artifact.missingReason === "artifact-filed-gstr2b-not-generated" + ? ["filed-gstr2b-not-generated"] + : artifact.missingReason === "artifact-filed-gstr1-excel-no-details-available" + ? ["filed-gstr1-excel-no-details-available"] + : []), ], ), ]), diff --git a/src/connectors/gst/filed-returns-declined-artifact.ts b/src/connectors/gst/filed-returns-declined-artifact.ts index 493b8561..0a50c221 100644 --- a/src/connectors/gst/filed-returns-declined-artifact.ts +++ b/src/connectors/gst/filed-returns-declined-artifact.ts @@ -6,7 +6,7 @@ import type { import { type DeclinedArtifactSignal } from "./filed-returns-acquisition-diagnostics"; import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; -import { verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; +import { isGstr2bSummaryRoute, verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; // Recording a refusal resolves a target outright: the period is answered, the run advances, and // no artifact ever follows to corroborate it. The visible page is therefore the whole of the @@ -52,6 +52,7 @@ export interface VisibleTargetBinding< */ export const REFUSAL_BINDING_SIGNALS = [ "filed-gstr1-detail-period-verified", + "gstr2b-summary-route-verified", "gstr2b-visible-period-verified", ] as const; @@ -129,10 +130,26 @@ export function bindGstr2bSummaryRefusal( ): RefusalBinding<"filed-gstr2b-not-generated", "GSTR-2B"> { if (scope.returnType !== "GSTR-2B") return { bound: null, mismatch: incompatibleScope(scope.returnType) }; + if (!isGstr2bSummaryRoute(documentRef)) { + return { + bound: null, + mismatch: { + connectorId: "gst", + scopeId: filedReturnScopeId("GSTR-2B"), + state: "blocked", + safeSignals: ["page-target-unverified"], + safeMessage: + "Pack could not verify that this portal refusal is on the GSTR-2B summary page.", + }, + }; + } const mismatch = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope, true); return mismatch ? { bound: null, mismatch } - : bound("filed-gstr2b-not-generated", "GSTR-2B", ["gstr2b-visible-period-verified"]); + : bound("filed-gstr2b-not-generated", "GSTR-2B", [ + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]); } /** From 39db6f562632f74020aa0ddad062b1b151fb1a66 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 17:28:45 +0530 Subject: [PATCH 04/48] test(gst): verify refusal persistence and resumed bundle cleanup --- ...turns-download-trigger-acquisition.test.ts | 30 ++- .../filed-returns-selected-artifacts.test.ts | 178 +++++++++++++++++- ...eturns-single-period-bundle-ledger.test.ts | 61 ++++++ .../filed-returns-declined-artifact.test.ts | 14 ++ 4 files changed, 268 insertions(+), 15 deletions(-) diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index d7411afc..4372ecc9 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -1033,13 +1033,8 @@ function acquiredJson(): PackMessageResponse { } describe("filed GSTR-1 e-invoice Excel with no details to download", () => { - // The portal answers this request with an information dialog when the taxpayer reports no - // e-invoices. The content script has always recognised it and the ledger has always known how to - // record the artifact as unavailable -- but nothing sent the message between them, so the - // recognition never ran and the run stalled offering a retry that cannot succeed. - // - // The message contract was tested; that it is ever sent was not. This pins the send, on the - // path that matters: the control was armed and clicked, and the click produced no download. + // A definitive acquisition failure may be refined by the typed, target-bound inspection result. + // An uncertain acquisition takes the recovery branch tested below and never reaches this path. function messagingDeps(postClick: PackMessageResponse) { return vi.fn(async (_tabId: number, message: { type: string }) => message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" @@ -1075,7 +1070,7 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { } as never); } - it("asks the page why, and adopts the portal's no-details answer", async () => { + it("adopts a target-bound no-details answer after a definitive acquisition failure", async () => { armDefinitiveNoActionFailure(); const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); @@ -1161,6 +1156,25 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { expect(captureMocks.persistArtifactAcquisitionIntent).toHaveBeenCalled(); }); + it("retains the checkpoint when terminal-decline persistence throws", async () => { + armDefinitiveNoActionFailure(); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + const clearedBefore = captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length; + summaryStorage.set.mockRejectedValueOnce(new Error("Synthetic storage failure.")); + + await expect( + triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: { completion: "completion" } }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }), + ).rejects.toThrow("Synthetic storage failure."); + + expect(captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length).toBe(clearedBefore); + }); + it("keeps the original failure when the page cannot be asked at all", async () => { // This runs after an acquisition has already failed and can only refine that failure. A tab // that has closed, navigated, or refuses injection means the failure cannot be refined -- not diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index 36bacaad..f44dfee4 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -8,11 +8,13 @@ import { concreteFiledReturnsArtifactTypesForSelection, type FiledReturnsConcreteArtifactType, } from "../../src/connectors/gst/filed-returns-artifacts"; +import { filedReturnScopeId } from "../../src/connectors/gst/filed-returns-return-descriptors"; import type { PackMessageResponse } from "../../src/connectors/gst/messages"; import type * as FiledReturnsArtifactProgressModule from "../../src/background/filed-returns-artifact-progress"; type SyntheticBundleArtifact = { artifactType: FiledReturnsConcreteArtifactType; + missingReason?: string; safeSignals: string[]; status: "pending" | "running" | "staged" | "unavailable"; }; @@ -88,13 +90,25 @@ const bundleMocks = vi.hoisted(() => { if (staged.length + missing.length === 0) return null; return { connectorId: "gst", - scopeId: "gst-gstr2b-private-v0", + scopeId: filedReturnScopeId("GSTR-2B"), state: missing.length > 0 ? "partial" : "downloaded", - safeSignals: staged.flatMap((artifact) => [ - "single-period-opfs-staged", - `single-period-opfs-staged:${artifact.artifactType}`, - `filed-return-artifact-downloaded:${artifact.artifactType}`, - ]), + safeSignals: Array.from( + new Set([ + "single-period-bundle-recovered", + ...staged.flatMap((artifact) => [ + "single-period-opfs-staged", + `single-period-opfs-staged:${artifact.artifactType}`, + `filed-return-artifact-downloaded:${artifact.artifactType}`, + ]), + ...missing.flatMap((artifact) => [ + `filed-return-artifact-unavailable:${artifact.artifactType}`, + artifact.missingReason!, + ...(artifact.missingReason === "artifact-filed-gstr2b-not-generated" + ? ["filed-gstr2b-not-generated"] + : []), + ]), + ]), + ), safeMessage: missing.length > 0 ? `Pack prepared a partial ZIP; missing ${missing @@ -128,6 +142,16 @@ const bundleMocks = vi.hoisted(() => { async (ledger: SyntheticBundleLedger, artifactType: FiledReturnsConcreteArtifactType) => transition(ledger, artifactType, "unavailable"), ), + persistSinglePeriodBundlePeriodUnavailable: vi.fn(async (ledger: SyntheticBundleLedger) => ({ + ...ledger, + artifacts: ledger.artifacts.map((artifact) => ({ + ...artifact, + missingReason: "artifact-filed-gstr2b-not-generated", + status: "unavailable" as const, + })), + phase: "ready-for-zip" as const, + revision: ledger.revision + 1, + })), persistSinglePeriodBundleCleanupPending: vi.fn(async (ledger: SyntheticBundleLedger) => ledger), persistSinglePeriodBundleZipDownloadId: vi.fn(async (ledger: SyntheticBundleLedger) => ledger), persistSinglePeriodBundleZipIntent: vi.fn(async (ledger: SyntheticBundleLedger) => ledger), @@ -169,7 +193,19 @@ const bundleMocks = vi.hoisted(() => { }; }); -vi.mock("wxt/browser", () => ({ browser: { storage: { local: {}, session: {} } } })); +const browserMocks = vi.hoisted(() => ({ + sessionRemove: vi.fn(async () => undefined), + sessionSet: vi.fn(async () => undefined), +})); + +vi.mock("wxt/browser", () => ({ + browser: { + storage: { + local: {}, + session: { remove: browserMocks.sessionRemove, set: browserMocks.sessionSet }, + }, + }, +})); vi.mock("../../src/background/filed-returns-artifact-progress", async (importOriginal) => ({ ...(await importOriginal()), ...mocks, @@ -196,6 +232,8 @@ const gstr2bAllFormatsArtifacts = concreteFiledReturnsArtifactTypesForSelection( describe("GSTR-2B all-format selection", () => { beforeEach(() => { vi.clearAllMocks(); + browserMocks.sessionRemove.mockResolvedValue(undefined); + browserMocks.sessionSet.mockResolvedValue(undefined); mocks.readPersistedArtifactProgress.mockResolvedValue(null); mocks.persistPartialArtifactSummary.mockImplementation(async (scope, flowStep) => ({ scope, @@ -483,6 +521,118 @@ describe("GSTR-2B all-format selection", () => { }); }); + it("resolves a bound GSTR-2B absence for the whole selection without making an empty ZIP", async () => { + mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( + blocked("PDF", "filed-gstr2b-not-generated"), + ); + + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + tabId: 17, + }); + + expect(mocks.triggerAndObserveFiledReturnDownload).toHaveBeenCalledOnce(); + expect(bundleMocks.persistSinglePeriodBundlePeriodUnavailable).toHaveBeenCalledOnce(); + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledOnce(); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["artifact-filed-gstr2b-not-generated"]), + state: "blocked", + }, + flowSummary: { status: "complete" }, + }); + }); + + it("finishes a resumed all-unavailable GSTR-2B bundle without another portal action", async () => { + const ledger = allUnavailableGstr2bBundle(); + bundleMocks.reserveSinglePeriodBundleLedger.mockResolvedValueOnce({ + ledger: ledger as never, + state: "existing", + }); + const sendMessageToTabWithInjection = vi.fn(); + + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + sendMessageToTabWithInjection, + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: ledger.scope, + tabId: 17, + }); + + expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); + expect(sendMessageToTabWithInjection).not.toHaveBeenCalled(); + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["filed-gstr2b-not-generated"]), + state: "blocked", + }, + flowSummary: { status: "complete" }, + }); + expect(browserMocks.sessionSet).toHaveBeenCalledOnce(); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledOnce(); + expect(browserMocks.sessionSet.mock.invocationCallOrder[0]).toBeLessThan( + bundleMocks.clearSinglePeriodBundleLedger.mock.invocationCallOrder[0]!, + ); + }); + + it("keeps the completed absence ledger resumable when its terminal summary cannot persist", async () => { + mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( + blocked("PDF", "filed-gstr2b-not-generated"), + ); + browserMocks.sessionSet.mockRejectedValueOnce(new Error("Synthetic session write failure.")); + + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + tabId: 17, + }); + + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "single-period-bundle-state-persist-failed", + "single-period-opfs-retained", + ]), + state: "blocked", + }, + flowSummary: { status: "blocked" }, + }); + }); + it("does not reuse direct-download progress while staging a fiscal-year artifact ledger", async () => { mocks.readPersistedArtifactProgress.mockResolvedValue({ completedArtifactTypes: ["PDF"], @@ -651,3 +801,17 @@ function retainedGstr2bBundle(): SyntheticBundleLedger { }, }; } + +function allUnavailableGstr2bBundle(): SyntheticBundleLedger { + return { + ...retainedGstr2bBundle(), + artifacts: retainedGstr2bBundle().artifacts.map((artifact) => ({ + ...artifact, + missingReason: "artifact-filed-gstr2b-not-generated", + safeSignals: ["single-period-bundle-artifact-unavailable"], + status: "unavailable" as const, + })), + phase: "ready-for-zip", + revision: 8, + }; +} 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 a02a70e3..2de068de 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -11,9 +11,11 @@ import { markSinglePeriodBundleArtifactRunning, markSinglePeriodBundleArtifactStaged, markSinglePeriodBundleArtifactUnavailable, + markSinglePeriodBundlePeriodUnavailable, persistSinglePeriodBundleArtifactRunning, persistSinglePeriodBundleArtifactStaged, persistSinglePeriodBundleArtifactUnavailable, + persistSinglePeriodBundlePeriodUnavailable, persistSinglePeriodBundleCleanupPending, persistSinglePeriodBundleZipDownloadId, persistSinglePeriodBundleZipIntent, @@ -373,6 +375,65 @@ describe("single-period bundle ledger", () => { ); }); + it("resolves an evidenced GSTR-2B absence across every pending bundle artifact", () => { + const scope = { ...GSTR1_SCOPE, returnType: "GSTR-2B" } as const; + const initial = createSinglePeriodBundleLedger( + scope, + "single-period:12345678-gstr2b", + CREATED_AT, + )!; + const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; + + const resolved = markSinglePeriodBundlePeriodUnavailable( + running, + { + connectorId: "gst", + scopeId: "gst-filed-returns-gstr2b-private-v0", + state: "blocked", + safeSignals: ["filed-gstr2b-not-generated"], + safeMessage: "Synthetic declined statement.", + }, + PDF_STAGED_AT, + ); + + expect(resolved?.phase).toBe("ready-for-zip"); + expect(resolved?.artifacts.every((artifact) => artifact.status === "unavailable")).toBe(true); + expect(singlePeriodBundleEntryPlan(resolved!)).toEqual({ + artifactTypes: [], + unavailableArtifactTypes: ["PDF", "EXCEL", "JSON"], + }); + }); + + it("rebuilds the bound GSTR-2B absence after a restart between ledger and summary writes", async () => { + const scope = { ...GSTR1_SCOPE, returnType: "GSTR-2B" } as const; + const initial = createSinglePeriodBundleLedger( + scope, + "single-period:12345678-gstr2b", + CREATED_AT, + )!; + localValues[STORAGE_KEY] = initial; + const running = await persistSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT); + const resolved = await persistSinglePeriodBundlePeriodUnavailable( + running!, + { + connectorId: "gst", + scopeId: "gst-filed-returns-gstr2b-private-v0", + state: "blocked", + safeSignals: ["filed-gstr2b-not-generated"], + safeMessage: "Synthetic declined statement.", + }, + PDF_STAGED_AT, + ); + + const reloaded = await readSinglePeriodBundleLedgerStorageState(); + + expect(reloaded).toMatchObject({ state: "valid", ledger: { revision: resolved?.revision } }); + if (reloaded.state !== "valid") throw new Error("Expected retained ledger."); + expect(singlePeriodBundleFlowStep(reloaded.ledger)?.safeSignals).toContain( + "filed-gstr2b-not-generated", + ); + }); + // 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. diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts index d0c39c38..de988467 100644 --- a/tests/connectors/filed-returns-declined-artifact.test.ts +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -72,6 +72,19 @@ describe("a declined artifact cannot be recorded unbound", () => { expect(binding.bound).toBeNull(); }); + it("refuses to bind matching text outside the GSTR-2B summary route", () => { + const documentRef = refusalPage("April"); + documentRef.defaultView?.history.replaceState({}, "", "/returns/auth/filed-returns"); + + const binding = bindGstr2bSummaryRefusal( + documentRef, + normaliseText(documentRef.body.innerText || documentRef.body.textContent || ""), + SCOPE, + ); + + expect(binding.bound).toBeNull(); + }); + it("refuses an Excel-only GSTR-1 decline for a PDF target", () => { const binding = bindGstr1DetailRefusal(refusalPage("April"), { actionId: "synthetic-action", @@ -98,6 +111,7 @@ describe("a declined artifact cannot be recorded unbound", () => { }); expect(step.state).toBe("blocked"); + expect(step.safeSignals).toContain("gstr2b-summary-route-verified"); expect(step.safeSignals).toContain("gstr2b-visible-period-verified"); expect(step.safeSignals).toContain("filed-gstr2b-not-generated"); // A refusal is terminal, so its signals are persisted. One token the allowlist has never been From d72e299a5dad7bf53f691fbb45cf077d488a857f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 18:41:48 +0530 Subject: [PATCH 05/48] fix(gst): retain mismatched recovery state --- .../filed-returns-durable-summary.ts | 6 +++++- .../filed-returns-selected-artifacts.ts | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/background/filed-returns-durable-summary.ts b/src/background/filed-returns-durable-summary.ts index 2995571a..5257363a 100644 --- a/src/background/filed-returns-durable-summary.ts +++ b/src/background/filed-returns-durable-summary.ts @@ -227,8 +227,12 @@ function isConsistentCompleteSummary({ flowStep.downloadDiagnostics === undefined ); } + const isGstr1ExcelNoDetails = + scope.returnType === "GSTR-1" && + normaliseFiledReturnsArtifactType(scope.returnType, scope.artifactType) === "EXCEL" && + flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available"); if ( - flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available") || + isGstr1ExcelNoDetails || (scope.returnType === "GSTR-2B" && flowStep.safeSignals.includes("filed-gstr2b-not-generated")) ) { return ( diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index dfff8993..f3c4a0f6 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -463,10 +463,23 @@ export async function triggerSelectedArtifacts({ ); } if (summary) { - await clearSinglePeriodBundleLedger( - singlePeriodBundleLedger.ledgerId, - singlePeriodBundleLedger.revision, - ); + let bundleCleared = false; + try { + bundleCleared = await clearSinglePeriodBundleLedger( + singlePeriodBundleLedger.ledgerId, + singlePeriodBundleLedger.revision, + ); + } catch { + bundleCleared = false; + } + if (!bundleCleared) { + return singlePeriodBundleBlockedResponse( + scope, + ["single-period-bundle-clear-failed", "single-period-opfs-retained"], + "Pack recorded that no files were available, but could not clear the saved recovery state.", + true, + ); + } } return { ...response, From 947983eb73d1352e3e3c72d30714386a45cff323 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 18:41:48 +0530 Subject: [PATCH 06/48] test(gst): cover durable recovery boundaries --- .../filed-returns-selected-artifacts.test.ts | 87 +++++++++++++++++++ ...led-returns-session-write-boundary.test.ts | 80 +++++++++++------ 2 files changed, 142 insertions(+), 25 deletions(-) diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index f44dfee4..8d939793 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -633,6 +633,93 @@ describe("GSTR-2B all-format selection", () => { }); }); + it("retains the completed absence ledger when its exact clear returns false", async () => { + mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( + blocked("PDF", "filed-gstr2b-not-generated"), + ); + bundleMocks.clearSinglePeriodBundleLedger.mockResolvedValueOnce(false); + + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + tabId: 17, + }); + + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + expect(browserMocks.sessionSet).toHaveBeenCalledWith({ + completion: expect.objectContaining({ status: "complete" }), + }); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledOnce(); + expect(browserMocks.sessionSet.mock.invocationCallOrder[0]).toBeLessThan( + bundleMocks.clearSinglePeriodBundleLedger.mock.invocationCallOrder[0]!, + ); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "single-period-bundle-clear-failed", + "single-period-opfs-retained", + ]), + state: "blocked", + }, + flowSummary: { status: "blocked" }, + }); + }); + + it("retains the completed absence ledger when its exact clear throws", async () => { + mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( + blocked("PDF", "filed-gstr2b-not-generated"), + ); + bundleMocks.clearSinglePeriodBundleLedger.mockRejectedValueOnce( + new Error("Synthetic ledger clear failure."), + ); + + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps: { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + scope: { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + tabId: 17, + }); + + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + expect(browserMocks.sessionSet).toHaveBeenCalledWith({ + completion: expect.objectContaining({ status: "complete" }), + }); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledOnce(); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "single-period-bundle-clear-failed", + "single-period-opfs-retained", + ]), + state: "blocked", + }, + flowSummary: { status: "blocked" }, + }); + }); + it("does not reuse direct-download progress while staging a fiscal-year artifact ledger", async () => { mocks.readPersistedArtifactProgress.mockResolvedValue({ completedArtifactTypes: ["PDF"], diff --git a/tests/background/filed-returns-session-write-boundary.test.ts b/tests/background/filed-returns-session-write-boundary.test.ts index 16b19656..7286656a 100644 --- a/tests/background/filed-returns-session-write-boundary.test.ts +++ b/tests/background/filed-returns-session-write-boundary.test.ts @@ -13,7 +13,10 @@ import { type ArtifactFailureReason, } from "../../src/connectors/gst/artifact-source"; import { isDurableFiledReturnsSignal } from "../../src/connectors/gst/filed-returns-durable-signals"; -import type { FiledReturnsFlowSummary } from "../../src/connectors/gst/filed-returns-contracts"; +import type { + FiledReturnsDownloadScope, + FiledReturnsFlowSummary, +} from "../../src/connectors/gst/filed-returns-contracts"; import { FILED_RETURNS_RETURN_TYPES, filedReturnsScopeId, @@ -84,32 +87,27 @@ describe("filed-return session write boundary", () => { expect(JSON.stringify(storage.session[COMPLETION_KEY])).not.toContain("account-specific"); }); - it("does not complete a GSTR-2B scope from a GSTR-1 Excel decline signal", async () => { - const scope = { - artifactType: "PDF" as const, - financialYear: "2026-27", - period: "April", - returnType: "GSTR-2B" as const, - }; + it.each<[string, FiledReturnsDownloadScope]>([ + ["a GSTR-2B PDF target", singlePeriodScope("GSTR-2B", "PDF")], + ["a GSTR-1 PDF target", singlePeriodScope("GSTR-1", "PDF")], + ["a GSTR-1 selected-artifact bundle", singlePeriodScope("GSTR-1", "PDF_AND_EXCEL")], + ["a GSTR-3B PDF target", singlePeriodScope("GSTR-3B", "PDF")], + ])( + "rejects a stale complete summary for %s with a GSTR-1 Excel decline signal", + async (_label, scope) => { + storage.session[COMPLETION_KEY] = gstr1ExcelNoDetailsCompleteSummary(scope); - const response = await withPersistedSinglePeriodSummary( - scope, - { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnsScopeId(scope.returnType), - state: "blocked", - safeSignals: ["filed-gstr1-excel-no-details-available"], - safeMessage: "Synthetic incompatible decline.", - }, - }, - deps, - true, - ); + await expect(readCanonicalFiledReturnsFlowSummary(COMPLETION_KEY)).resolves.toBeNull(); + expect(storage.session[COMPLETION_KEY]).toBeUndefined(); + }, + ); - expect(response).toMatchObject({ - flowSummary: { completedPeriods: [], status: "blocked" }, + it("accepts the GSTR-1 Excel decline only for its exact selected target", async () => { + const scope = singlePeriodScope("GSTR-1", "EXCEL"); + + storage.session[COMPLETION_KEY] = gstr1ExcelNoDetailsCompleteSummary(scope); + await expect(readCanonicalFiledReturnsFlowSummary(COMPLETION_KEY)).resolves.toMatchObject({ + status: "complete", }); }); @@ -1463,6 +1461,38 @@ describe("filed-return session write boundary", () => { }); }); +function singlePeriodScope( + returnType: FiledReturnsDownloadScope["returnType"], + artifactType: NonNullable, +): FiledReturnsDownloadScope { + return { + artifactType, + financialYear: "2026-27", + period: "April", + returnType, + }; +} + +function gstr1ExcelNoDetailsCompleteSummary( + scope: FiledReturnsDownloadScope, +): FiledReturnsFlowSummary { + return { + scope, + status: "complete", + completedAt: "2026-07-24T00:00:00.000Z", + completedPeriods: [scope.period], + currentPeriod: scope.period, + totalPeriods: 1, + flowStep: { + connectorId: "gst", + scopeId: filedReturnsScopeId(scope.returnType), + state: "blocked", + safeSignals: ["filed-gstr1-excel-no-details-available"], + safeMessage: "Synthetic scoped decline.", + }, + }; +} + function singlePeriodSummary( flowStepOverrides: Record = {}, ): FiledReturnsFlowSummary { From 755029acd1e1680597116d9199ec51a2c7e4a09a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 18:43:10 +0530 Subject: [PATCH 07/48] fix(gst): keep cleanup failure summaries durable --- src/background/filed-returns-selected-artifacts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index f3c4a0f6..a64386c0 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -475,7 +475,7 @@ export async function triggerSelectedArtifacts({ if (!bundleCleared) { return singlePeriodBundleBlockedResponse( scope, - ["single-period-bundle-clear-failed", "single-period-opfs-retained"], + ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], "Pack recorded that no files were available, but could not clear the saved recovery state.", true, ); From 282c48a537a5e999744acbdb0aba71a3e66afa4f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 18:43:10 +0530 Subject: [PATCH 08/48] test(gst): persist retained cleanup state --- .../filed-returns-selected-artifacts.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index 8d939793..59609394 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -223,6 +223,7 @@ import { preflightSelectedArtifactsRecovery, triggerSelectedArtifacts, } from "../../src/background/filed-returns-selected-artifacts"; +import { withPersistedSinglePeriodSummary } from "../../src/background/filed-returns-single-period-summary"; const gstr2bAllFormatsArtifacts = concreteFiledReturnsArtifactTypesForSelection( "GSTR-2B", @@ -668,13 +669,34 @@ describe("GSTR-2B all-format selection", () => { expect(response).toMatchObject({ flowStep: { safeSignals: expect.arrayContaining([ - "single-period-bundle-clear-failed", + "single-period-bundle-state-persist-failed", "single-period-opfs-retained", ]), state: "blocked", }, flowSummary: { status: "blocked" }, }); + const persisted = await withPersistedSinglePeriodSummary( + { + artifactType: "PDF_AND_EXCEL", + financialYear: "2026-27", + period: "June", + returnType: "GSTR-2B", + }, + response as never, + { + storageKeys: { + completion: "completion", + fullFiscalYearLedger: "ledger", + observation: "observation", + }, + } as never, + true, + ); + expect(persisted).toMatchObject({ flowSummary: { status: "blocked" } }); + expect(browserMocks.sessionSet).toHaveBeenLastCalledWith({ + completion: expect.objectContaining({ status: "blocked" }), + }); }); it("retains the completed absence ledger when its exact clear throws", async () => { @@ -711,7 +733,7 @@ describe("GSTR-2B all-format selection", () => { expect(response).toMatchObject({ flowStep: { safeSignals: expect.arrayContaining([ - "single-period-bundle-clear-failed", + "single-period-bundle-state-persist-failed", "single-period-opfs-retained", ]), state: "blocked", From 699ae226a2cc06356228e095dcb872bcabb4975a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:05:42 +0530 Subject: [PATCH 09/48] fix(gst): reconcile empty bundle cleanup before portal lookup --- .../filed-returns-selected-artifacts.ts | 103 ++++++++++-------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index a64386c0..24ff522f 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -114,6 +114,15 @@ export async function preflightSelectedArtifactsRecovery({ if (!compatibleDurableSinglePeriodBundle) { return conflictingSinglePeriodBundleResponse(ledger); } + if ( + ledger.phase === "ready-for-zip" && + singlePeriodBundleEntryPlan(ledger)?.artifactTypes.length === 0 + ) { + const flowStep = singlePeriodBundleFlowStep(ledger); + return flowStep + ? completeUnavailableSinglePeriodBundle(ledger, { ok: true, flowStep }, deps) + : staleSinglePeriodBundleResponse(ledger); + } return null; } @@ -440,52 +449,7 @@ export async function triggerSelectedArtifacts({ const entryPlan = singlePeriodBundleEntryPlan(singlePeriodBundleLedger); if (!entryPlan) return staleSinglePeriodBundleResponse(singlePeriodBundleLedger); if (entryPlan.artifactTypes.length === 0) { - const terminalStep: PortalFlowStepResult = { - ...response.flowStep, - state: "blocked", - safeMessage: - "Pack recorded the selected artifacts as unavailable, so it did not create a ZIP.", - }; - let summary; - try { - summary = await persistCanonicalSinglePeriodCompletion( - artifactDeps.storageKeys.completion, - scope, - terminalStep, - deps.now?.() ?? new Date(), - ); - } catch { - return singlePeriodBundleBlockedResponse( - scope, - ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], - "Pack retained the selected-file recovery state because it could not save the terminal absence.", - true, - ); - } - if (summary) { - let bundleCleared = false; - try { - bundleCleared = await clearSinglePeriodBundleLedger( - singlePeriodBundleLedger.ledgerId, - singlePeriodBundleLedger.revision, - ); - } catch { - bundleCleared = false; - } - if (!bundleCleared) { - return singlePeriodBundleBlockedResponse( - scope, - ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], - "Pack recorded that no files were available, but could not clear the saved recovery state.", - true, - ); - } - } - return { - ...response, - flowStep: terminalStep, - ...(summary ? { flowSummary: summary } : {}), - }; + return completeUnavailableSinglePeriodBundle(singlePeriodBundleLedger, response, artifactDeps); } if (!response.flowStep.safeSignals.includes("single-period-opfs-staged")) { return staleSinglePeriodBundleResponse(singlePeriodBundleLedger); @@ -649,6 +613,53 @@ export async function triggerSelectedArtifacts({ }; } +async function completeUnavailableSinglePeriodBundle( + ledger: SinglePeriodBundleLedger, + response: Extract, + deps: FiledReturnsFlowRunnerDeps, +): Promise { + const scope = ledger.scope; + const terminalStep: PortalFlowStepResult = { + ...response.flowStep, + state: "blocked", + safeMessage: "Pack recorded the selected artifacts as unavailable, so it did not create a ZIP.", + }; + let summary; + try { + summary = await persistCanonicalSinglePeriodCompletion( + deps.storageKeys.completion, + scope, + terminalStep, + deps.now?.() ?? new Date(), + ); + } catch { + summary = null; + } + if (!summary) { + return singlePeriodBundleBlockedResponse( + scope, + ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], + "Pack retained the selected-file recovery state because it could not save the terminal absence.", + true, + ); + } + let bundleCleared = false; + try { + bundleCleared = await clearSinglePeriodBundleLedger(ledger.ledgerId, ledger.revision); + } catch { + bundleCleared = false; + } + if (!bundleCleared) { + return singlePeriodBundleBlockedResponse( + scope, + ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], + "Pack recorded that no files were available, but could not clear the saved recovery state.", + true, + ); + } + return { ...response, flowStep: terminalStep, flowSummary: summary }; +} + function withArtifactOutcome( flowStep: PortalFlowStepResult, artifactType: FiledReturnsConcreteArtifactType, From 9b2515f20fa7daea371abf20beb96e4a332bebe0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:05:42 +0530 Subject: [PATCH 10/48] test(gst): exercise local absence cleanup through the entrypoint --- .../filed-returns-selected-artifacts.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index 59609394..fe1aa54b 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -29,6 +29,7 @@ type SyntheticBundleLedger = { }; const mocks = vi.hoisted(() => ({ + reconcileArtifactAcquisitionCheckpoint: vi.fn(async () => ({ state: "none" })), combineDownloadedArtifactFlowSteps: vi.fn( (combined: PortalFlowStepResult | null, next: PortalFlowStepResult) => ({ ...next, @@ -206,6 +207,9 @@ vi.mock("wxt/browser", () => ({ }, }, })); +vi.mock("../../src/background/artifact-acquisition-state", () => ({ + reconcileArtifactAcquisitionCheckpoint: mocks.reconcileArtifactAcquisitionCheckpoint, +})); vi.mock("../../src/background/filed-returns-artifact-progress", async (importOriginal) => ({ ...(await importOriginal()), ...mocks, @@ -223,6 +227,7 @@ import { preflightSelectedArtifactsRecovery, triggerSelectedArtifacts, } from "../../src/background/filed-returns-selected-artifacts"; +import { startSinglePeriodFiledReturnsDownloadFlow } from "../../src/background/filed-returns-single-period-flow"; import { withPersistedSinglePeriodSummary } from "../../src/background/filed-returns-single-period-summary"; const gstr2bAllFormatsArtifacts = concreteFiledReturnsArtifactTypesForSelection( @@ -597,6 +602,115 @@ describe("GSTR-2B all-format selection", () => { ); }); + it.each(["closed tab", "visible refusal"])( + "retries absence cleanup before looking for a portal with %s", + async (portalState) => { + const ledger = allUnavailableGstr2bBundle(); + bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ + ledger, + state: "valid", + }); + const getActiveGstTab = vi.fn(async () => (portalState === "closed tab" ? null : { id: 17 })); + const sendMessageToTabWithInjection = vi.fn(async () => + blocked("PDF", "filed-gstr2b-not-generated"), + ); + + const response = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, { + getActiveGstTab, + sendMessageToTabWithInjection, + storageKeys: { completion: "completion" }, + } as never); + + expect(response).toMatchObject({ flowSummary: { status: "complete" } }); + expect(getActiveGstTab).not.toHaveBeenCalled(); + expect(sendMessageToTabWithInjection).not.toHaveBeenCalled(); + expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledExactlyOnceWith( + ledger.ledgerId, + ledger.revision, + ); + expect(browserMocks.sessionSet.mock.invocationCallOrder[0]).toBeLessThan( + bundleMocks.clearSinglePeriodBundleLedger.mock.invocationCallOrder[0]!, + ); + }, + ); + + it.each(["false", "throw", "summary write"])( + "can retry retained absence cleanup locally after %s failure", + async (failure) => { + const ledger = allUnavailableGstr2bBundle(); + bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ + ledger, + state: "valid", + }); + if (failure === "false") + bundleMocks.clearSinglePeriodBundleLedger.mockResolvedValueOnce(false); + if (failure === "throw") { + bundleMocks.clearSinglePeriodBundleLedger.mockRejectedValueOnce( + new Error("Synthetic clear failure."), + ); + } + if (failure === "summary write") { + browserMocks.sessionSet.mockRejectedValueOnce(new Error("Synthetic summary failure.")); + } + const getActiveGstTab = vi.fn(async () => null); + const deps = { getActiveGstTab, storageKeys: { completion: "completion" } } as never; + + const failed = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, deps); + expect(failed).toMatchObject({ + flowStep: { state: "blocked", userAction: { canResume: true } }, + flowSummary: { status: "blocked" }, + }); + if (failure === "summary write") { + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); + } + const retried = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, deps); + expect(retried).toMatchObject({ flowSummary: { status: "complete" } }); + expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledTimes( + failure === "summary write" ? 1 : 2, + ); + expect(getActiveGstTab).not.toHaveBeenCalled(); + expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + }, + ); + + it("retains absence recovery when no terminal summary key is available", async () => { + const ledger = allUnavailableGstr2bBundle(); + bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ + ledger, + state: "valid", + }); + const response = await preflightSelectedArtifactsRecovery({ + deps: { storageKeys: {} } as never, + scope: ledger.scope, + }); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["single-period-bundle-state-persist-failed"]), + state: "blocked", + userAction: { canResume: true }, + }, + flowSummary: { status: "blocked" }, + }); + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); + expect(browserMocks.sessionSet).not.toHaveBeenCalled(); + }); + + it("does not treat a retained bundle with staged files as zero-artifact cleanup", async () => { + const ledger = { ...retainedGstr2bBundle(), phase: "ready-for-zip" as const }; + bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ + ledger, + state: "valid", + }); + await expect( + preflightSelectedArtifactsRecovery({ deps: {} as never, scope: ledger.scope }), + ).resolves.toBeNull(); + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); + expect(browserMocks.sessionSet).not.toHaveBeenCalled(); + }); + it("keeps the completed absence ledger resumable when its terminal summary cannot persist", async () => { mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( blocked("PDF", "filed-gstr2b-not-generated"), From 12e22cb091f4c2fb35e9aa75e54f319c5d6198f9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:05:26 +0530 Subject: [PATCH 11/48] fix(gst): bind bundle missing reasons to artifacts --- ...led-returns-single-period-bundle-ledger.ts | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index 6498acd5..c960652a 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -396,7 +396,7 @@ export function markSinglePeriodBundleArtifactUnavailable( return null; } const diagnostic = optionalArtifactDiagnostic(flowStep, ledger.scope, artifactType); - const missingReason = missingArtifactReason(flowStep, ledger.scope.returnType); + const missingReason = missingArtifactReason(flowStep, ledger.scope.returnType, artifactType); if (!missingReason) return null; const updated = updateArtifact(ledger, artifactType, now, { artifactType, @@ -426,14 +426,16 @@ export function markSinglePeriodBundlePeriodUnavailable( ) { return null; } - const missingReason = missingArtifactReason(flowStep, ledger.scope.returnType); - if (!missingReason) return null; + const missingReasons = ledger.artifacts.map((artifact) => + missingArtifactReason(flowStep, ledger.scope.returnType, artifact.artifactType), + ); + if (missingReasons.some((missingReason) => !missingReason)) return null; const timestamp = now.toISOString(); const updated = nextLedger(ledger, now, { - artifacts: ledger.artifacts.map((artifact) => ({ + artifacts: ledger.artifacts.map((artifact, index) => ({ artifactType: artifact.artifactType, completedAt: timestamp, - missingReason, + missingReason: missingReasons[index]!, safeSignals: ["single-period-bundle-artifact-unavailable"], startedAt: artifact.startedAt ?? timestamp, status: "unavailable" as const, @@ -674,6 +676,11 @@ function parseArtifact( } const diagnostic = optionalArtifactDiagnostic(artifact, scope, expectedArtifactType); if (artifact.downloadDiagnostic !== undefined && !diagnostic) return null; + const missingReason = normaliseArtifactMissingReason( + artifact.missingReason, + scope.returnType, + expectedArtifactType, + ); if (artifact.status === "pending") { if ( @@ -694,11 +701,7 @@ function parseArtifact( if (expectedArtifactType === "PDF" && diagnostic.mimeClass !== "pdf") return null; if (expectedArtifactType === "JSON" && diagnostic.mimeClass !== "json") return null; if (expectedArtifactType === "EXCEL" && diagnostic.mimeClass !== "spreadsheet") return null; - } else if ( - !artifact.startedAt || - !artifact.completedAt || - !isMissingReason(artifact.missingReason) - ) { + } else if (!artifact.startedAt || !artifact.completedAt || !missingReason) { return null; } @@ -706,7 +709,7 @@ function parseArtifact( artifactType: expectedArtifactType, ...(artifact.completedAt ? { completedAt: artifact.completedAt } : {}), ...(diagnostic ? { downloadDiagnostic: diagnostic } : {}), - ...(artifact.missingReason ? { missingReason: artifact.missingReason } : {}), + ...(missingReason ? { missingReason } : {}), safeSignals: expectedSignals, ...(artifact.startedAt ? { startedAt: artifact.startedAt } : {}), status: artifact.status, @@ -917,18 +920,31 @@ function parsedArtifactPlan( function missingArtifactReason( flowStep: PortalFlowStepResult, returnType: FiledReturnsDownloadScope["returnType"], + artifactType: FiledReturnsConcreteArtifactType, ): string | 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)); - const compatible = - (declined === "filed-gstr1-excel-no-details-available" && returnType === "GSTR-1") || - (declined === "filed-gstr2b-not-generated" && returnType === "GSTR-2B"); - return compatible ? (DECLINED_ARTIFACT_REASONS.get(declined) ?? null) : null; + return ( + flowStep.safeSignals + .map((signal) => normaliseArtifactMissingReason(signal, returnType, artifactType)) + .find((reason): reason is string => reason !== null) ?? null + ); } -function isMissingReason(value: unknown): value is string { - return typeof value === "string" && MISSING_ARTIFACT_REASONS.has(value); +function normaliseArtifactMissingReason( + value: unknown, + returnType: FiledReturnsDownloadScope["returnType"], + artifactType: FiledReturnsConcreteArtifactType, +): string | null { + if (typeof value !== "string") return null; + const reason = MISSING_ARTIFACT_REASONS.has(value) + ? value + : (DECLINED_ARTIFACT_REASONS.get(value) ?? null); + if (!reason) return null; + if (reason === "artifact-filed-gstr1-excel-no-details-available") { + return returnType === "GSTR-1" && artifactType === "EXCEL" ? reason : null; + } + return reason === "artifact-filed-gstr2b-not-generated" && returnType === "GSTR-2B" + ? reason + : null; } function bundleSafeMessage(ledger: SinglePeriodBundleLedger): string { From 93bcf3d1d183742f9dee025c571f114a27d286b5 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:05:26 +0530 Subject: [PATCH 12/48] test(gst): reject mismatched bundle absence state --- ...eturns-single-period-bundle-ledger.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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 2de068de..09a7606d 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -457,6 +457,52 @@ describe("single-period bundle ledger", () => { expect(updated).toBeNull(); }); + it("refuses to record the GSTR-1 Excel decline against a PDF artifact", () => { + const initial = requiredLedger(); + const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; + + expect( + markSinglePeriodBundleArtifactUnavailable( + running, + "PDF", + { + connectorId: "gst", + safeMessage: "Synthetic incompatible decline.", + safeSignals: ["filed-gstr1-excel-no-details-available"], + scopeId: "gst-filed-returns-gstr1-pdf-private-v0", + state: "blocked", + }, + PDF_STAGED_AT, + ), + ).toBeNull(); + }); + + it("keeps a stored GSTR-1 PDF artifact with an Excel-only missing reason malformed", async () => { + const initial = requiredLedger(); + localValues[STORAGE_KEY] = { + ...initial, + artifacts: [ + { + ...initial.artifacts[0], + completedAt: PDF_STAGED_AT.toISOString(), + missingReason: "artifact-filed-gstr1-excel-no-details-available", + safeSignals: ["single-period-bundle-artifact-unavailable"], + startedAt: PDF_RUNNING_AT.toISOString(), + status: "unavailable", + updatedAt: PDF_STAGED_AT.toISOString(), + }, + initial.artifacts[1], + ], + revision: 2, + updatedAt: PDF_STAGED_AT.toISOString(), + }; + + await expect(readSinglePeriodBundleLedgerStorageState()).resolves.toMatchObject({ + recoverableLedgerId: initial.ledgerId, + state: "malformed", + }); + }); + it("rejects non-enumerated artifact signals before they can enter durable state", () => { const initial = requiredLedger(); const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; From 13ce4223fb389d8a158fa3a76ba959872d7c1229 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:10:39 +0530 Subject: [PATCH 13/48] fix(gst): reject conflicting bundle refusal reasons --- ...led-returns-single-period-bundle-ledger.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index c960652a..aa6296b5 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -676,11 +676,9 @@ function parseArtifact( } const diagnostic = optionalArtifactDiagnostic(artifact, scope, expectedArtifactType); if (artifact.downloadDiagnostic !== undefined && !diagnostic) return null; - const missingReason = normaliseArtifactMissingReason( - artifact.missingReason, - scope.returnType, - expectedArtifactType, - ); + const missingReason = isMissingReason(artifact.missingReason) + ? normaliseArtifactMissingReason(artifact.missingReason, scope.returnType, expectedArtifactType) + : null; if (artifact.status === "pending") { if ( @@ -922,11 +920,11 @@ function missingArtifactReason( returnType: FiledReturnsDownloadScope["returnType"], artifactType: FiledReturnsConcreteArtifactType, ): string | null { - return ( - flowStep.safeSignals - .map((signal) => normaliseArtifactMissingReason(signal, returnType, artifactType)) - .find((reason): reason is string => reason !== null) ?? null - ); + const reasons = flowStep.safeSignals + .map(canonicalArtifactMissingReason) + .filter((reason): reason is string => reason !== null); + if (new Set(reasons).size !== 1) return null; + return normaliseArtifactMissingReason(reasons[0]!, returnType, artifactType); } function normaliseArtifactMissingReason( @@ -934,10 +932,7 @@ function normaliseArtifactMissingReason( returnType: FiledReturnsDownloadScope["returnType"], artifactType: FiledReturnsConcreteArtifactType, ): string | null { - if (typeof value !== "string") return null; - const reason = MISSING_ARTIFACT_REASONS.has(value) - ? value - : (DECLINED_ARTIFACT_REASONS.get(value) ?? null); + const reason = canonicalArtifactMissingReason(value); if (!reason) return null; if (reason === "artifact-filed-gstr1-excel-no-details-available") { return returnType === "GSTR-1" && artifactType === "EXCEL" ? reason : null; @@ -947,6 +942,17 @@ function normaliseArtifactMissingReason( : null; } +function canonicalArtifactMissingReason(value: unknown): string | null { + if (typeof value !== "string") return null; + return MISSING_ARTIFACT_REASONS.has(value) + ? value + : (DECLINED_ARTIFACT_REASONS.get(value) ?? null); +} + +function isMissingReason(value: unknown): value is string { + return typeof value === "string" && MISSING_ARTIFACT_REASONS.has(value); +} + function bundleSafeMessage(ledger: SinglePeriodBundleLedger): string { const missing = ledger.artifacts .filter((artifact) => artifact.status === "unavailable") From ef887f2834d877ca0b81cf14eae47fec67cddcdf Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:10:39 +0530 Subject: [PATCH 14/48] test(gst): preserve canonical bundle reason storage --- ...eturns-single-period-bundle-ledger.test.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) 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 09a7606d..63fb9501 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -45,6 +45,10 @@ const GSTR1_SCOPE = { period: "April", returnType: "GSTR-1", } as const satisfies FiledReturnsDownloadScope; +const GSTR2B_SCOPE = { + ...GSTR1_SCOPE, + returnType: "GSTR-2B", +} as const satisfies FiledReturnsDownloadScope; const CREATED_AT = new Date("2026-07-24T00:00:00.000Z"); const PDF_RUNNING_AT = new Date("2026-07-24T00:00:01.000Z"); const PDF_STAGED_AT = new Date("2026-07-24T00:00:02.000Z"); @@ -457,6 +461,53 @@ describe("single-period bundle ledger", () => { expect(updated).toBeNull(); }); + it("refuses a prefixed GSTR-2B reason on a GSTR-1 artifact", () => { + const initial = requiredLedger(); + const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; + + expect( + markSinglePeriodBundleArtifactUnavailable( + running, + "PDF", + { + connectorId: "gst", + safeMessage: "Synthetic incompatible decline.", + safeSignals: ["artifact-filed-gstr2b-not-generated"], + scopeId: "gst-filed-returns-gstr1-pdf-private-v0", + state: "blocked", + }, + PDF_STAGED_AT, + ), + ).toBeNull(); + }); + + it("fails closed when a GSTR-1 Excel artifact carries conflicting declined reasons", () => { + const initial = requiredLedger(); + const pdfRunning = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; + const pdfStaged = markSinglePeriodBundleArtifactStaged( + pdfRunning, + "PDF", + stagedStep(GSTR1_SCOPE, "PDF"), + PDF_STAGED_AT, + )!; + const running = markSinglePeriodBundleArtifactRunning(pdfStaged, "EXCEL", EXCEL_RUNNING_AT)!; + + expect( + markSinglePeriodBundleArtifactUnavailable( + running, + "EXCEL", + { + connectorId: "gst", + safeMessage: "Synthetic conflicting decline.", + safeSignals: ["filed-gstr1-excel-no-details-available", "filed-gstr2b-not-generated"], + scopeId: "gst-filed-returns-gstr1-pdf-private-v0", + state: "blocked", + }, + EXCEL_STAGED_AT, + ), + ).toBeNull(); + }); + it("refuses to record the GSTR-1 Excel decline against a PDF artifact", () => { const initial = requiredLedger(); const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; @@ -503,6 +554,54 @@ describe("single-period bundle ledger", () => { }); }); + it.each([ + ["GSTR-1", GSTR1_SCOPE, "PDF", "artifact-filed-gstr2b-not-generated"], + ["GSTR-2B", GSTR2B_SCOPE, "PDF", "artifact-filed-gstr1-excel-no-details-available"], + ] as const)( + "keeps a stored cross-return refusal reason malformed for %s", + async (_returnType, scope, artifactType, missingReason) => { + const ledger = unavailableLedger(scope, artifactType, missingReason); + localValues[STORAGE_KEY] = ledger; + + await expect(readSinglePeriodBundleLedgerStorageState()).resolves.toMatchObject({ + recoverableLedgerId: ledger.ledgerId, + state: "malformed", + }); + }, + ); + + it("does not normalize a raw persisted decline signal into durable state", async () => { + const ledger = unavailableLedger( + GSTR1_SCOPE, + "EXCEL", + "filed-gstr1-excel-no-details-available", + ); + localValues[STORAGE_KEY] = ledger; + + await expect(readSinglePeriodBundleLedgerStorageState()).resolves.toMatchObject({ + recoverableLedgerId: ledger.ledgerId, + state: "malformed", + }); + }); + + it.each([ + [GSTR1_SCOPE, "EXCEL", "artifact-filed-gstr1-excel-no-details-available"], + [GSTR2B_SCOPE, "PDF", "artifact-filed-gstr2b-not-generated"], + ] as const)( + "restores a compatible canonical missing reason", + async (scope, artifactType, missingReason) => { + const ledger = unavailableLedger(scope, artifactType, missingReason); + localValues[STORAGE_KEY] = ledger; + + const restored = await readSinglePeriodBundleLedgerStorageState(); + expect(restored).toMatchObject({ state: "valid" }); + if (restored.state !== "valid") throw new Error("Expected compatible ledger."); + expect(restored.ledger.artifacts).toEqual( + expect.arrayContaining([expect.objectContaining({ missingReason })]), + ); + }, + ); + it("rejects non-enumerated artifact signals before they can enter durable state", () => { const initial = requiredLedger(); const running = markSinglePeriodBundleArtifactRunning(initial, "PDF", PDF_RUNNING_AT)!; @@ -689,6 +788,48 @@ function requiredLedger() { return createSinglePeriodBundleLedger(GSTR1_SCOPE, "single-period:12345678-durable", CREATED_AT)!; } +function unavailableLedger( + scope: FiledReturnsDownloadScope, + artifactType: "PDF" | "EXCEL" | "JSON", + missingReason: string, +) { + const initial = createSinglePeriodBundleLedger( + scope, + "single-period:12345678-durable", + CREATED_AT, + )!; + return { + ...initial, + artifacts: initial.artifacts.map((artifact) => { + if (artifact.artifactType === artifactType) { + return { + ...artifact, + completedAt: PDF_STAGED_AT.toISOString(), + missingReason, + safeSignals: ["single-period-bundle-artifact-unavailable"], + startedAt: PDF_RUNNING_AT.toISOString(), + status: "unavailable" as const, + updatedAt: PDF_STAGED_AT.toISOString(), + }; + } + if (artifactType === "EXCEL" && artifact.artifactType === "PDF") { + return { + ...artifact, + completedAt: PDF_STAGED_AT.toISOString(), + downloadDiagnostic: diagnostic(scope, "PDF", "downloaded"), + safeSignals: ["single-period-bundle-artifact-staged", "single-period-opfs-staged:PDF"], + startedAt: PDF_RUNNING_AT.toISOString(), + status: "staged" as const, + updatedAt: PDF_STAGED_AT.toISOString(), + }; + } + return artifact; + }), + revision: 2, + updatedAt: PDF_STAGED_AT.toISOString(), + }; +} + async function persistBothArtifacts() { const initial = requiredLedger(); localValues[STORAGE_KEY] = initial; From 3aac9936761e97f4a6d65e96c5f4cbd305877ec8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:19:00 +0530 Subject: [PATCH 15/48] refactor(gst): derive recorded refusal reasons from canonical signals --- src/background/filed-returns-single-period-bundle-ledger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index aa6296b5..0f6170d5 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -934,10 +934,10 @@ function normaliseArtifactMissingReason( ): string | null { const reason = canonicalArtifactMissingReason(value); if (!reason) return null; - if (reason === "artifact-filed-gstr1-excel-no-details-available") { + if (reason === declinedArtifactReason("filed-gstr1-excel-no-details-available")) { return returnType === "GSTR-1" && artifactType === "EXCEL" ? reason : null; } - return reason === "artifact-filed-gstr2b-not-generated" && returnType === "GSTR-2B" + return reason === declinedArtifactReason("filed-gstr2b-not-generated") && returnType === "GSTR-2B" ? reason : null; } From 0e7c0e49d63d81907b73662a53530e1a5c0affe6 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:50:02 +0530 Subject: [PATCH 16/48] refactor(gst): centralize scoped refusal proof recognition --- .../gst/filed-returns-declined-artifact.ts | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/connectors/gst/filed-returns-declined-artifact.ts b/src/connectors/gst/filed-returns-declined-artifact.ts index 0a50c221..c587c845 100644 --- a/src/connectors/gst/filed-returns-declined-artifact.ts +++ b/src/connectors/gst/filed-returns-declined-artifact.ts @@ -3,7 +3,12 @@ import type { FiledReturnsDownloadScope, FiledReturnsDownloadTarget, } from "./filed-returns-contracts"; -import { type DeclinedArtifactSignal } from "./filed-returns-acquisition-diagnostics"; +import { + DECLINED_ARTIFACT_SIGNALS, + type DeclinedArtifactSignal, +} from "./filed-returns-acquisition-diagnostics"; +import { normaliseFiledReturnsArtifactType } from "./filed-returns-artifacts"; +import { FULL_FISCAL_YEAR_PERIOD } from "./filed-returns-scope"; import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; import { isGstr2bSummaryRoute, verifyVisibleGstr2bPeriod } from "./gstr2b-summary"; @@ -50,12 +55,45 @@ export interface VisibleTargetBinding< * entire array it travels in, and a refusal is a terminal step whose signals are persisted -- * so a terminal refusal remains readable after persistence. */ +const DECLINED_ARTIFACT_BINDING_SIGNALS = { + "filed-gstr1-excel-no-details-available": ["filed-gstr1-detail-period-verified"], + "filed-gstr2b-not-generated": ["gstr2b-summary-route-verified", "gstr2b-visible-period-verified"], +} as const; + export const REFUSAL_BINDING_SIGNALS = [ - "filed-gstr1-detail-period-verified", - "gstr2b-summary-route-verified", - "gstr2b-visible-period-verified", + ...DECLINED_ARTIFACT_BINDING_SIGNALS["filed-gstr1-excel-no-details-available"], + ...DECLINED_ARTIFACT_BINDING_SIGNALS["filed-gstr2b-not-generated"], ] as const; +export function getBoundDeclinedArtifactSignal( + scope: FiledReturnsDownloadScope, + safeSignals: readonly string[], +): DeclinedArtifactSignal | null { + if ( + scope.period === FULL_FISCAL_YEAR_PERIOD || + safeSignals.includes("filed-return-positively-not-filed") + ) + return null; + const signals = DECLINED_ARTIFACT_SIGNALS.filter((signal) => safeSignals.includes(signal)); + if (signals.length !== 1) return null; + const signal = signals[0]!; + const requiredProofs: readonly string[] = DECLINED_ARTIFACT_BINDING_SIGNALS[signal]; + if ( + REFUSAL_BINDING_SIGNALS.some( + (proof) => safeSignals.includes(proof) && !requiredProofs.includes(proof), + ) + ) + return null; + const compatibleScope = + signal === "filed-gstr1-excel-no-details-available" + ? scope.returnType === "GSTR-1" && + normaliseFiledReturnsArtifactType(scope.returnType, scope.artifactType) === "EXCEL" + : scope.returnType === "GSTR-2B"; + return compatibleScope && requiredProofs.every((proof) => safeSignals.includes(proof)) + ? signal + : null; +} + export type RefusalBindingSignal = (typeof REFUSAL_BINDING_SIGNALS)[number]; export type RefusalBinding< @@ -111,9 +149,11 @@ export function bindGstr1DetailRefusal( const mismatch = verifyFiledReturnsDownloadTarget(documentRef, target, []); return mismatch ? { bound: null, mismatch } - : bound("filed-gstr1-excel-no-details-available", "GSTR-1", [ - "filed-gstr1-detail-period-verified", - ]); + : bound( + "filed-gstr1-excel-no-details-available", + "GSTR-1", + DECLINED_ARTIFACT_BINDING_SIGNALS["filed-gstr1-excel-no-details-available"], + ); } /** @@ -146,10 +186,11 @@ export function bindGstr2bSummaryRefusal( const mismatch = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope, true); return mismatch ? { bound: null, mismatch } - : bound("filed-gstr2b-not-generated", "GSTR-2B", [ - "gstr2b-summary-route-verified", - "gstr2b-visible-period-verified", - ]); + : bound( + "filed-gstr2b-not-generated", + "GSTR-2B", + DECLINED_ARTIFACT_BINDING_SIGNALS["filed-gstr2b-not-generated"], + ); } /** From c5c08f60e3fffd3c6c8a90129227c3fd4aff13ec Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:54:14 +0530 Subject: [PATCH 17/48] fix(gst): require binding proof for recovered refusal completion --- .../filed-returns-durable-summary.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/background/filed-returns-durable-summary.ts b/src/background/filed-returns-durable-summary.ts index 5257363a..9bc76e2f 100644 --- a/src/background/filed-returns-durable-summary.ts +++ b/src/background/filed-returns-durable-summary.ts @@ -1,4 +1,6 @@ import type { UserActionRequired } from "../core/contracts"; +import { getBoundDeclinedArtifactSignal } from "../connectors/gst/filed-returns-declined-artifact"; +import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; import type { FiledReturnsDownloadScope, FiledReturnsFlowSummary, @@ -220,23 +222,17 @@ function isConsistentCompleteSummary({ ) { return false; } - if (flowStep.safeSignals.includes("filed-return-positively-not-filed")) { + if (DECLINED_ARTIFACT_SIGNALS.some((signal) => flowStep.safeSignals.includes(signal))) { return ( - flowStep.state === "candidate-not-found" && + getBoundDeclinedArtifactSignal(scope, flowStep.safeSignals) !== null && + flowStep.state === "blocked" && flowStep.downloadDiagnostic === undefined && flowStep.downloadDiagnostics === undefined ); } - const isGstr1ExcelNoDetails = - scope.returnType === "GSTR-1" && - normaliseFiledReturnsArtifactType(scope.returnType, scope.artifactType) === "EXCEL" && - flowStep.safeSignals.includes("filed-gstr1-excel-no-details-available"); - if ( - isGstr1ExcelNoDetails || - (scope.returnType === "GSTR-2B" && flowStep.safeSignals.includes("filed-gstr2b-not-generated")) - ) { + if (flowStep.safeSignals.includes("filed-return-positively-not-filed")) { return ( - flowStep.state === "blocked" && + flowStep.state === "candidate-not-found" && flowStep.downloadDiagnostic === undefined && flowStep.downloadDiagnostics === undefined ); From 307d91e776f2cd244ea7c3d2fa2307033d8d834f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:54:14 +0530 Subject: [PATCH 18/48] test(gst): reject unproved recovered refusal summaries --- ...led-returns-session-write-boundary.test.ts | 69 ++++++++++++++++++- .../filed-returns-declined-artifact.test.ts | 31 +++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/tests/background/filed-returns-session-write-boundary.test.ts b/tests/background/filed-returns-session-write-boundary.test.ts index 7286656a..a9ca323f 100644 --- a/tests/background/filed-returns-session-write-boundary.test.ts +++ b/tests/background/filed-returns-session-write-boundary.test.ts @@ -69,6 +69,73 @@ describe("filed-return session write boundary", () => { vi.clearAllMocks(); }); + it.each([ + [ + "GSTR-1 Excel without detail proof", + "GSTR-1", + "EXCEL", + ["filed-gstr1-excel-no-details-available"], + ], + ["GSTR-2B without proof", "GSTR-2B", "PDF", ["filed-gstr2b-not-generated"]], + [ + "GSTR-2B without route proof", + "GSTR-2B", + "PDF", + ["filed-gstr2b-not-generated", "gstr2b-visible-period-verified"], + ], + [ + "GSTR-2B without period proof", + "GSTR-2B", + "PDF", + ["filed-gstr2b-not-generated", "gstr2b-summary-route-verified"], + ], + [ + "GSTR-1 with the wrong binding proof", + "GSTR-1", + "EXCEL", + [ + "filed-gstr1-excel-no-details-available", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + ], + [ + "GSTR-1 with conflicting refusal signals", + "GSTR-1", + "EXCEL", + [ + "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", + "filed-gstr1-detail-period-verified", + ], + ], + ] as const)( + "rejects recovered completion for %s", + async (_label, returnType, artifactType, signals) => { + const scope = singlePeriodScope(returnType, artifactType); + const summary = gstr1ExcelNoDetailsCompleteSummary(scope); + summary.flowStep.safeSignals = [...signals]; + storage.session[COMPLETION_KEY] = summary; + + await expect(readCanonicalFiledReturnsFlowSummary(COMPLETION_KEY)).resolves.toBeNull(); + expect(storage.session[COMPLETION_KEY]).toBeUndefined(); + }, + ); + + it("accepts recovered GSTR-2B absence only with both binding proofs", async () => { + const scope = singlePeriodScope("GSTR-2B", "PDF"); + const summary = gstr1ExcelNoDetailsCompleteSummary(scope); + summary.flowStep.safeSignals = [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]; + storage.session[COMPLETION_KEY] = summary; + await expect(readCanonicalFiledReturnsFlowSummary(COMPLETION_KEY)).resolves.toMatchObject({ + status: "complete", + }); + }); + it("reconstructs summary prose before direct persistence", async () => { const summary = await persistCanonicalFiledReturnsFlowSummary( COMPLETION_KEY, @@ -1487,7 +1554,7 @@ function gstr1ExcelNoDetailsCompleteSummary( connectorId: "gst", scopeId: filedReturnsScopeId(scope.returnType), state: "blocked", - safeSignals: ["filed-gstr1-excel-no-details-available"], + safeSignals: ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], safeMessage: "Synthetic scoped decline.", }, }; diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts index de988467..83f6d0be 100644 --- a/tests/connectors/filed-returns-declined-artifact.test.ts +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -9,7 +9,9 @@ import { bindGstr2bSummaryRefusal, declinedArtifactSafeMessage, declinedArtifactStep, + getBoundDeclinedArtifactSignal, } from "../../src/connectors/gst/filed-returns-declined-artifact"; +import { FULL_FISCAL_YEAR_PERIOD } from "../../src/connectors/gst/filed-returns-scope"; import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; import { normaliseText } from "../../src/connectors/gst/filed-returns-dom"; @@ -110,6 +112,35 @@ describe("a declined artifact cannot be recorded unbound", () => { safeSignals: ["gstr2b-summary-route"], }); + expect(getBoundDeclinedArtifactSignal(SCOPE, step.safeSignals)).toBe( + "filed-gstr2b-not-generated", + ); + expect( + getBoundDeclinedArtifactSignal( + { ...SCOPE, period: FULL_FISCAL_YEAR_PERIOD }, + step.safeSignals, + ), + ).toBeNull(); + for (const missing of ["gstr2b-summary-route-verified", "gstr2b-visible-period-verified"]) { + expect( + getBoundDeclinedArtifactSignal( + SCOPE, + step.safeSignals.filter((signal) => signal !== missing), + ), + ).toBeNull(); + } + expect( + getBoundDeclinedArtifactSignal(SCOPE, [ + ...step.safeSignals, + "filed-gstr1-detail-period-verified", + ]), + ).toBeNull(); + expect( + getBoundDeclinedArtifactSignal(SCOPE, [ + ...step.safeSignals, + "filed-return-positively-not-filed", + ]), + ).toBeNull(); expect(step.state).toBe("blocked"); expect(step.safeSignals).toContain("gstr2b-summary-route-verified"); expect(step.safeSignals).toContain("gstr2b-visible-period-verified"); From 87740ba95169c2806459047386fc85e9a0e6c5d7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:57:40 +0530 Subject: [PATCH 19/48] fix(popup): present stated artifact absences --- .../gst/filed-returns-durable-status.ts | 8 +- src/entrypoints/popup/inline-status.tsx | 14 +++- src/entrypoints/popup/pack-summary.tsx | 7 ++ src/entrypoints/popup/presentation-state.ts | 19 +++++ .../filed-returns-declined-artifact.test.ts | 38 +++++++++ tests/popup/inline-status.test.tsx | 60 +++++++++++++- tests/popup/pack-summary.test.tsx | 35 ++++++++ tests/popup/presentation-state.test.ts | 83 +++++++++++++++++++ 8 files changed, 260 insertions(+), 4 deletions(-) diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index 65f02958..c130ae34 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -1,4 +1,7 @@ -import { declinedArtifactSafeMessage } from "./filed-returns-declined-artifact"; +import { + declinedArtifactSafeMessage, + getBoundDeclinedArtifactSignal, +} from "./filed-returns-declined-artifact"; import { FILED_RETURNS_FILTER_DEADLINE_EXPIRED_MESSAGE, filedReturnsFilterActionRequiredMessage, @@ -167,6 +170,9 @@ export function canonicalDurableSummaryMessage( status: FiledReturnsFlowSummary["status"], signals: readonly string[], ): string { + const declinedArtifactSignal = + status === "complete" ? getBoundDeclinedArtifactSignal(scope, signals) : null; + if (declinedArtifactSignal) return declinedArtifactSafeMessage(declinedArtifactSignal); const mismatchedReturnType = visibleReturnTypeMismatch(scope, status, signals); if (mismatchedReturnType) { return incompleteReturnTypeMismatchRecoveryMessage(scope, mismatchedReturnType); diff --git a/src/entrypoints/popup/inline-status.tsx b/src/entrypoints/popup/inline-status.tsx index 9f44ae52..0095faf7 100644 --- a/src/entrypoints/popup/inline-status.tsx +++ b/src/entrypoints/popup/inline-status.tsx @@ -1,5 +1,9 @@ import React from "react"; import type { FiledReturnsFlowSummary } from "../../connectors/gst/filed-returns-contracts"; +import { + declinedArtifactSafeMessage, + getBoundDeclinedArtifactSignal, +} from "../../connectors/gst/filed-returns-declined-artifact"; import { FULL_FISCAL_YEAR_PERIOD } from "../../connectors/gst/filed-returns-scope"; import { filedReturnsPlanCoverageMessage } from "../../connectors/gst/filed-returns-durable-status"; import type { PopupPresentationState } from "./presentation-state"; @@ -252,10 +256,16 @@ function getInlineStatusCopy( }; } if (presentation.kind === "unavailable") { + const declinedArtifactSignal = + summary?.status === "complete" + ? getBoundDeclinedArtifactSignal(summary.scope, summary.flowStep.safeSignals) + : null; return { - body: "The GST Portal did not report a filed return for this selection.", + body: declinedArtifactSignal + ? declinedArtifactSafeMessage(declinedArtifactSignal) + : "The GST Portal did not report a filed return for this selection.", icon: "–", - title: "No filed return found", + title: declinedArtifactSignal ? "No artifact available" : "No filed return found", tone: "neutral", }; } diff --git a/src/entrypoints/popup/pack-summary.tsx b/src/entrypoints/popup/pack-summary.tsx index 11f47e1d..e538f3ba 100644 --- a/src/entrypoints/popup/pack-summary.tsx +++ b/src/entrypoints/popup/pack-summary.tsx @@ -15,6 +15,7 @@ import { hasPersistedFullFiscalYearZipDownloadId, isAmbiguousFullFiscalYearZipHandoff, } from "./flow-summary"; +import { getBoundDeclinedArtifactSignal } from "../../connectors/gst/filed-returns-declined-artifact"; export function PackSummary({ scope, @@ -57,6 +58,12 @@ export function PackSummary({ function getSinglePeriodMeta(summary: FiledReturnsFlowSummary | null): string { const signals = new Set(summary?.flowStep.safeSignals ?? []); + if ( + summary?.status === "complete" && + getBoundDeclinedArtifactSignal(summary.scope, summary.flowStep.safeSignals) + ) { + return "No artifact available"; + } if (hasConfirmedSinglePeriodBrowserDownload(summary)) { return "Saved by your browser"; } diff --git a/src/entrypoints/popup/presentation-state.ts b/src/entrypoints/popup/presentation-state.ts index 7b79b8ed..ec46881c 100644 --- a/src/entrypoints/popup/presentation-state.ts +++ b/src/entrypoints/popup/presentation-state.ts @@ -1,5 +1,9 @@ import type { PortalContext } from "../../core/contracts"; import type { FiledReturnsFlowSummary } from "../../connectors/gst/filed-returns-contracts"; +import { + declinedArtifactSafeMessage, + getBoundDeclinedArtifactSignal, +} from "../../connectors/gst/filed-returns-declined-artifact"; import { FULL_FISCAL_YEAR_PERIOD } from "../../connectors/gst/filed-returns-scope"; import { canRetryFullFiscalYearZipWithoutPortal, @@ -90,6 +94,21 @@ export function getPopupPresentationState( return getUnsupportedContextState(context); } + const declinedArtifactSignal = + summary?.status === "complete" + ? getBoundDeclinedArtifactSignal(summary.scope, summary.flowStep.safeSignals) + : null; + if (declinedArtifactSignal) { + return { + badge: "Unavailable", + body: declinedArtifactSafeMessage(declinedArtifactSignal), + icon: "–", + kind: "unavailable", + title: "No artifact available", + tone: "neutral", + }; + } + if (summary?.flowStep.safeSignals.includes("filed-return-positively-not-filed")) { return { badge: "Unavailable", diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts index 83f6d0be..d0ddb81c 100644 --- a/tests/connectors/filed-returns-declined-artifact.test.ts +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -15,6 +15,7 @@ import { FULL_FISCAL_YEAR_PERIOD } from "../../src/connectors/gst/filed-returns- import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; import { normaliseText } from "../../src/connectors/gst/filed-returns-dom"; +import { canonicalDurableSummaryMessage } from "../../src/connectors/gst/filed-returns-durable-status"; const SCOPE = { artifactType: "PDF_AND_EXCEL", @@ -159,4 +160,41 @@ describe("the copy a declined artifact carries", () => { it("registers every binding signal for durable storage", () => { expect(parseDurableFiledReturnsSignals([...REFUSAL_BINDING_SIGNALS])).not.toBeNull(); }); + + it.each([ + [ + "GSTR-2B", + "PDF", + [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + "filed-gstr2b-not-generated" as const, + ], + [ + "GSTR-1", + "EXCEL", + ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], + "filed-gstr1-excel-no-details-available" as const, + ], + ] as const)( + "durably presents a %s absence without download completion copy", + (returnType, artifactType, signals, declinedSignal) => { + const message = canonicalDurableSummaryMessage( + { + artifactType, + financialYear: "2025-26", + period: "April", + returnType, + }, + "complete", + signals, + ); + + expect(message).toBe(declinedArtifactSafeMessage(declinedSignal)); + expect(message).not.toContain("completed the local filed-return download"); + expect(message).not.toContain("Browser Downloads"); + }, + ); }); diff --git a/tests/popup/inline-status.test.tsx b/tests/popup/inline-status.test.tsx index dcf485cb..cc3c96c4 100644 --- a/tests/popup/inline-status.test.tsx +++ b/tests/popup/inline-status.test.tsx @@ -8,7 +8,10 @@ import { hasInlinePrimaryAction, inlinePrimaryActionIsPortalGated, } from "../../src/entrypoints/popup/inline-status"; -import type { PopupPresentationState } from "../../src/entrypoints/popup/presentation-state"; +import { + getPopupPresentationState, + type PopupPresentationState, +} from "../../src/entrypoints/popup/presentation-state"; import { RecoveryActions } from "../../src/entrypoints/popup/recovery-actions"; const blockedPresentation: PopupPresentationState = { @@ -199,6 +202,61 @@ describe("single-period completion claim", () => { }); }); +describe("stated artifact absence presentation", () => { + it.each([ + [ + "GSTR-2B", + "PDF", + [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + "did not generate the auto-drafted GSTR-2B statement", + ], + [ + "GSTR-1", + "EXCEL", + ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], + "no e-invoice details are available", + ], + ] as const)( + "renders the canonical %s absence copy", + (returnType, artifactType, safeSignals, expectedBody) => { + const summary = singlePeriodSummary([...safeSignals]); + summary.scope = { + ...summary.scope, + returnType, + artifactType, + }; + const presentation = getPopupPresentationState( + { connectorId: "gst", pageKind: "gst-filed-returns", supported: true }, + summary, + null, + ); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/No artifact available/g)).toHaveLength(2); + expect(markup).toContain(expectedBody); + expect(markup).not.toContain("Browser Downloads"); + expect(markup).not.toContain("Browser download not confirmed"); + expect(markup).not.toContain("saved by your browser"); + }, + ); +}); + describe("inline filed-return recovery status", () => { it("renders the cancelled-run reset confirmation instead of dropping it", () => { const cancelledSummary: FiledReturnsFlowSummary = { diff --git a/tests/popup/pack-summary.test.tsx b/tests/popup/pack-summary.test.tsx index 2b0e4c53..efaa630a 100644 --- a/tests/popup/pack-summary.test.tsx +++ b/tests/popup/pack-summary.test.tsx @@ -66,6 +66,41 @@ describe("popup pack summary", () => { expect(markup).not.toContain("Saved by your browser"); }); + it.each([ + [ + "GSTR-2B", + "PDF", + [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + ], + [ + "GSTR-1", + "EXCEL", + ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], + ], + ] as const)( + "labels a bound %s absence as unavailable", + (returnType, artifactType, safeSignals) => { + const scope = { + ...singlePeriodScope, + returnType, + artifactType, + } as const; + const summary = singlePeriodSummary("complete"); + summary.scope = scope; + summary.flowStep.safeSignals = [...safeSignals]; + + const markup = renderToStaticMarkup(); + + expect(markup.match(/No artifact available/g)).toHaveLength(1); + expect(markup).not.toContain("Browser download not confirmed"); + expect(markup).not.toContain("Saved by your browser"); + }, + ); + it("keeps confirmed single-period ZIP copy when only local cleanup remains blocked", () => { const summary = singlePeriodSummary("blocked"); summary.flowStep.safeSignals = [ diff --git a/tests/popup/presentation-state.test.ts b/tests/popup/presentation-state.test.ts index 814a6e5b..ee37deb5 100644 --- a/tests/popup/presentation-state.test.ts +++ b/tests/popup/presentation-state.test.ts @@ -170,6 +170,87 @@ describe("popup presentation state", () => { }); expect(state.body).not.toContain("Browser Downloads"); }); + + it.each([ + [ + "GSTR-2B", + "PDF", + [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + ], + [ + "GSTR-1", + "EXCEL", + ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], + ], + ] as const)( + "presents a bound %s absence without a browser-download warning", + (returnType, artifactType, safeSignals) => { + const state = getPopupPresentationState( + supportedContext(), + summary("complete", [...safeSignals], { + returnType, + artifactType, + }), + null, + ); + + expect(state).toMatchObject({ + badge: "Unavailable", + kind: "unavailable", + title: "No artifact available", + tone: "neutral", + }); + expect(state.body).toContain("no"); + expect(state.body).not.toContain("Browser Downloads"); + expect(state.body).not.toContain("saved by your browser"); + }, + ); + + it("keeps an ordinary unconfirmed completion warning when absence proof is incomplete", () => { + const state = getPopupPresentationState( + supportedContext(), + summary("complete", ["filed-gstr2b-not-generated"], { + returnType: "GSTR-2B", + artifactType: "PDF", + }), + null, + ); + + expect(state).toMatchObject({ + badge: "Download unconfirmed", + kind: "complete", + title: "Browser download not confirmed", + tone: "warning", + }); + expect(state.body).toContain("Browser Downloads"); + }); + + it("keeps a blocked recovery state actionable even when it carries absence proof", () => { + const state = getPopupPresentationState( + supportedContext(), + summary( + "blocked", + [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + { + returnType: "GSTR-2B", + artifactType: "PDF", + }, + ), + null, + ); + + expect(state.kind).toBe("blocked"); + expect(state.title).toContain("needs attention"); + expect(state.body).toContain("Retry"); + }); }); function supportedContext(): PortalContext { @@ -200,6 +281,7 @@ function accessDeniedContext(): PortalContext { function summary( status: FiledReturnsFlowSummary["status"], safeSignals: string[], + scopeOverrides: Partial = {}, ): FiledReturnsFlowSummary { const base: Omit = { scope: { @@ -207,6 +289,7 @@ function summary( period: "May", returnType: "GSTR-3B", artifactType: "PDF", + ...scopeOverrides, }, status, completedPeriods: status === "complete" ? ["May"] : [], From d6c470f895b9bedf91f514716a19919e7ad19402 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 19:58:37 +0530 Subject: [PATCH 20/48] test(popup): preserve blocked absence recovery --- .../filed-returns-declined-artifact.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts index d0ddb81c..517e1b93 100644 --- a/tests/connectors/filed-returns-declined-artifact.test.ts +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -197,4 +197,15 @@ describe("the copy a declined artifact carries", () => { expect(message).not.toContain("Browser Downloads"); }, ); + + it("does not turn blocked refusal recovery into a settled absence", () => { + const message = canonicalDurableSummaryMessage(SCOPE, "blocked", [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]); + + expect(message).toContain("needs an explicit recovery action"); + expect(message).not.toContain("there is nothing for Pack to download"); + }); }); From e169c2023b0d844758789e2417908be3ef2587a1 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 23:54:49 +0530 Subject: [PATCH 21/48] fix(gst): retain bundle absence without run-bound proof --- .../filed-returns-selected-artifacts.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/background/filed-returns-selected-artifacts.ts b/src/background/filed-returns-selected-artifacts.ts index 24ff522f..38a90a3f 100644 --- a/src/background/filed-returns-selected-artifacts.ts +++ b/src/background/filed-returns-selected-artifacts.ts @@ -32,6 +32,7 @@ import { readFiledReturnsTargetReview, } from "./filed-returns-target-review"; import { persistCanonicalSinglePeriodCompletion } from "./filed-returns-session-summary"; +import { getBoundDeclinedArtifactSignal } from "../connectors/gst/filed-returns-declined-artifact"; import { persistFiledReturnsTargetDownloadId, persistFiledReturnsTargetDownloadIntent, @@ -619,8 +620,18 @@ async function completeUnavailableSinglePeriodBundle( deps: FiledReturnsFlowRunnerDeps, ): Promise { const scope = ledger.scope; + const proofSignals = response.flowStep.safeSignals; + if (!getBoundDeclinedArtifactSignal(scope, proofSignals)) { + return singlePeriodBundleBlockedResponse( + scope, + ["single-period-bundle-artifact-result-unavailable", "single-period-opfs-retained"], + "Pack could not verify the recorded refusal proof, so it retained the selected-file recovery state for review.", + false, + ); + } const terminalStep: PortalFlowStepResult = { ...response.flowStep, + safeSignals: proofSignals, state: "blocked", safeMessage: "Pack recorded the selected artifacts as unavailable, so it did not create a ZIP.", }; @@ -638,9 +649,9 @@ async function completeUnavailableSinglePeriodBundle( if (!summary) { return singlePeriodBundleBlockedResponse( scope, - ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], - "Pack retained the selected-file recovery state because it could not save the terminal absence.", - true, + [...proofSignals, "single-period-bundle-state-persist-failed", "single-period-opfs-retained"], + "Pack retained the selected-file recovery state for review because it could not save the terminal absence.", + false, ); } let bundleCleared = false; @@ -652,9 +663,9 @@ async function completeUnavailableSinglePeriodBundle( if (!bundleCleared) { return singlePeriodBundleBlockedResponse( scope, - ["single-period-bundle-state-persist-failed", "single-period-opfs-retained"], - "Pack recorded that no files were available, but could not clear the saved recovery state.", - true, + [...proofSignals, "single-period-bundle-state-persist-failed", "single-period-opfs-retained"], + "Pack recorded that no files were available, but could not clear the saved recovery state. Review the retained state before starting again.", + false, ); } return { ...response, flowStep: terminalStep, flowSummary: summary }; @@ -881,6 +892,7 @@ function singlePeriodBundleResponse( scope, status: "blocked", totalPeriods: 1, + updatedAt: new Date().toISOString(), }, }; } From 866b2f2616350a25ca979c841ba76654a4a6f594 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 23:54:49 +0530 Subject: [PATCH 22/48] test(gst): preserve unproved bundle recovery for review --- .../filed-returns-selected-artifacts.test.ts | 159 ++++++++++++++---- 1 file changed, 127 insertions(+), 32 deletions(-) diff --git a/tests/background/filed-returns-selected-artifacts.test.ts b/tests/background/filed-returns-selected-artifacts.test.ts index fe1aa54b..71860bb7 100644 --- a/tests/background/filed-returns-selected-artifacts.test.ts +++ b/tests/background/filed-returns-selected-artifacts.test.ts @@ -194,16 +194,26 @@ const bundleMocks = vi.hoisted(() => { }; }); +const sessionState = vi.hoisted(() => ({ values: {} as Record })); const browserMocks = vi.hoisted(() => ({ - sessionRemove: vi.fn(async () => undefined), - sessionSet: vi.fn(async () => undefined), + sessionGet: vi.fn(async (key: string) => ({ [key]: sessionState.values[key] })), + sessionRemove: vi.fn(async (key: string) => { + delete sessionState.values[key]; + }), + sessionSet: vi.fn(async (values: Record) => { + Object.assign(sessionState.values, values); + }), })); vi.mock("wxt/browser", () => ({ browser: { storage: { local: {}, - session: { remove: browserMocks.sessionRemove, set: browserMocks.sessionSet }, + session: { + get: browserMocks.sessionGet, + remove: browserMocks.sessionRemove, + set: browserMocks.sessionSet, + }, }, }, })); @@ -238,8 +248,16 @@ const gstr2bAllFormatsArtifacts = concreteFiledReturnsArtifactTypesForSelection( describe("GSTR-2B all-format selection", () => { beforeEach(() => { vi.clearAllMocks(); - browserMocks.sessionRemove.mockResolvedValue(undefined); - browserMocks.sessionSet.mockResolvedValue(undefined); + sessionState.values = {}; + browserMocks.sessionGet.mockImplementation(async (key) => ({ + [key]: sessionState.values[key], + })); + browserMocks.sessionRemove.mockImplementation(async (key) => { + delete sessionState.values[key]; + }); + browserMocks.sessionSet.mockImplementation(async (values) => { + Object.assign(sessionState.values, values); + }); mocks.readPersistedArtifactProgress.mockResolvedValue(null); mocks.persistPartialArtifactSummary.mockImplementation(async (scope, flowStep) => ({ scope, @@ -563,8 +581,9 @@ describe("GSTR-2B all-format selection", () => { }); }); - it("finishes a resumed all-unavailable GSTR-2B bundle without another portal action", async () => { + it("retains an all-unavailable ledger whose binding proof cannot be recovered", async () => { const ledger = allUnavailableGstr2bBundle(); + await seedPersistedAbsenceProof(ledger); bundleMocks.reserveSinglePeriodBundleLedger.mockResolvedValueOnce({ ledger: ledger as never, state: "existing", @@ -590,22 +609,20 @@ describe("GSTR-2B all-format selection", () => { expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); expect(response).toMatchObject({ flowStep: { - safeSignals: expect.arrayContaining(["filed-gstr2b-not-generated"]), + safeSignals: expect.arrayContaining(["single-period-opfs-retained"]), state: "blocked", }, - flowSummary: { status: "complete" }, + flowSummary: { status: "blocked" }, }); - expect(browserMocks.sessionSet).toHaveBeenCalledOnce(); - expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledOnce(); - expect(browserMocks.sessionSet.mock.invocationCallOrder[0]).toBeLessThan( - bundleMocks.clearSinglePeriodBundleLedger.mock.invocationCallOrder[0]!, - ); + expect(browserMocks.sessionSet).not.toHaveBeenCalled(); + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); }); it.each(["closed tab", "visible refusal"])( - "retries absence cleanup before looking for a portal with %s", + "holds unproved absence recovery before looking for a portal with %s", async (portalState) => { const ledger = allUnavailableGstr2bBundle(); + await seedPersistedAbsenceProof(ledger); bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ ledger, state: "valid", @@ -621,23 +638,17 @@ describe("GSTR-2B all-format selection", () => { storageKeys: { completion: "completion" }, } as never); - expect(response).toMatchObject({ flowSummary: { status: "complete" } }); + expect(response).toMatchObject({ flowSummary: { status: "blocked" } }); expect(getActiveGstTab).not.toHaveBeenCalled(); expect(sendMessageToTabWithInjection).not.toHaveBeenCalled(); expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); - expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledExactlyOnceWith( - ledger.ledgerId, - ledger.revision, - ); - expect(browserMocks.sessionSet.mock.invocationCallOrder[0]).toBeLessThan( - bundleMocks.clearSinglePeriodBundleLedger.mock.invocationCallOrder[0]!, - ); + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); }, ); it.each(["false", "throw", "summary write"])( - "can retry retained absence cleanup locally after %s failure", + "retains current refusal proof and blocks its summary after %s failure", async (failure) => { const ledger = allUnavailableGstr2bBundle(); bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ @@ -657,25 +668,82 @@ describe("GSTR-2B all-format selection", () => { const getActiveGstTab = vi.fn(async () => null); const deps = { getActiveGstTab, storageKeys: { completion: "completion" } } as never; - const failed = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, deps); + mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( + blocked("PDF", "filed-gstr2b-not-generated"), + ); + const response = await triggerSelectedArtifacts({ + activePeriod: "June", + deps, + scope: ledger.scope, + tabId: 17, + }); + if (!response.ok || !("flowStep" in response)) throw new Error("Expected a flow response."); + const failed = await withPersistedSinglePeriodSummary(ledger.scope, response, deps, true); expect(failed).toMatchObject({ - flowStep: { state: "blocked", userAction: { canResume: true } }, + flowStep: { state: "blocked", userAction: { canResume: false } }, flowSummary: { status: "blocked" }, }); if (failure === "summary write") { expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); } const retried = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, deps); - expect(retried).toMatchObject({ flowSummary: { status: "complete" } }); + expect(retried).toMatchObject({ + flowSummary: { status: "blocked" }, + flowStep: { userAction: { canResume: false } }, + }); expect(bundleMocks.clearSinglePeriodBundleLedger).toHaveBeenCalledTimes( - failure === "summary write" ? 1 : 2, + failure === "summary write" ? 0 : 1, ); expect(getActiveGstTab).not.toHaveBeenCalled(); - expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); + expect(mocks.triggerAndObserveFiledReturnDownload).toHaveBeenCalledOnce(); expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); }, ); + it.each([ + "missing", + "wrong scope", + "incomplete proof", + "legacy refusal", + "unrelated blocked state", + "prior run with the same scope", + ])("retains all-unavailable recovery with %s session proof", async (failure) => { + const ledger = allUnavailableGstr2bBundle(); + await seedPersistedAbsenceProof(ledger); + const saved = sessionState.values.completion as FiledReturnsFlowSummary; + if (failure === "missing") delete sessionState.values.completion; + if (failure === "wrong scope") saved.scope.period = "July"; + if (failure === "incomplete proof") { + saved.flowStep.safeSignals = saved.flowStep.safeSignals.filter( + (signal) => signal !== "gstr2b-visible-period-verified", + ); + } + if (failure === "legacy refusal") { + saved.flowStep.safeSignals = saved.flowStep.safeSignals.filter( + (signal) => !signal.startsWith("gstr2b-"), + ); + } + if (failure === "unrelated blocked state") saved.status = "blocked"; + bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ + ledger, + state: "valid", + }); + const getActiveGstTab = vi.fn(); + const response = await startSinglePeriodFiledReturnsDownloadFlow(ledger.scope, { + getActiveGstTab, + storageKeys: { completion: "completion" }, + } as never); + expect(response).toMatchObject({ + flowStep: { state: "blocked", userAction: { canResume: false } }, + flowSummary: { status: "blocked" }, + }); + expect(sessionState.values.completion).toMatchObject({ status: "blocked" }); + expect(bundleMocks.clearSinglePeriodBundleLedger).not.toHaveBeenCalled(); + expect(getActiveGstTab).not.toHaveBeenCalled(); + expect(mocks.triggerAndObserveFiledReturnDownload).not.toHaveBeenCalled(); + expect(bundleMocks.exportSinglePeriodFiledReturnsZip).not.toHaveBeenCalled(); + }); + it("retains absence recovery when no terminal summary key is available", async () => { const ledger = allUnavailableGstr2bBundle(); bundleMocks.readSinglePeriodBundleLedgerStorageState.mockResolvedValue({ @@ -688,9 +756,9 @@ describe("GSTR-2B all-format selection", () => { }); expect(response).toMatchObject({ flowStep: { - safeSignals: expect.arrayContaining(["single-period-bundle-state-persist-failed"]), + safeSignals: expect.arrayContaining(["single-period-bundle-artifact-result-unavailable"]), state: "blocked", - userAction: { canResume: true }, + userAction: { canResume: false }, }, flowSummary: { status: "blocked" }, }); @@ -711,7 +779,7 @@ describe("GSTR-2B all-format selection", () => { expect(browserMocks.sessionSet).not.toHaveBeenCalled(); }); - it("keeps the completed absence ledger resumable when its terminal summary cannot persist", async () => { + it("keeps the completed absence ledger for review when its terminal summary cannot persist", async () => { mocks.triggerAndObserveFiledReturnDownload.mockResolvedValueOnce( blocked("PDF", "filed-gstr2b-not-generated"), ); @@ -999,7 +1067,12 @@ function blocked(artifactType: string, safeSignal = "artifact-generation-timeout connectorId: "gst", scopeId: "gst-gstr2b-private-v0", state: "blocked", - safeSignals: [safeSignal], + safeSignals: [ + safeSignal, + ...(safeSignal === "filed-gstr2b-not-generated" + ? ["gstr2b-summary-route-verified", "gstr2b-visible-period-verified"] + : []), + ], safeMessage: `${artifactType} failed.`, }, }; @@ -1038,3 +1111,25 @@ function allUnavailableGstr2bBundle(): SyntheticBundleLedger { revision: 8, }; } + +async function seedPersistedAbsenceProof(ledger: SyntheticBundleLedger): Promise { + const flowStep = bundleMocks.singlePeriodBundleFlowStep(ledger)!; + await withPersistedSinglePeriodSummary( + ledger.scope, + { + ok: true, + flowStep: { + ...flowStep, + state: "blocked", + safeSignals: [ + ...flowStep.safeSignals, + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + }, + }, + { storageKeys: { completion: "completion" } } as never, + true, + ); + browserMocks.sessionSet.mockClear(); +} From 38e005043e6f254f297c5daee2e1097506f84cb7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:07:05 +0530 Subject: [PATCH 23/48] fix(gst): clear only the owned durable refusal review --- .../filed-returns-download-trigger.ts | 104 +++++++++++++++--- src/background/filed-returns-target-review.ts | 38 ++++++- 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 0919b018..16ec14a4 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -1,6 +1,7 @@ import type { FiledReturnsDownloadScope, FiledReturnsDownloadDiagnostic, + FiledReturnsTargetDownloadAttempt, PortalFlowStepResult, } from "../connectors/gst/filed-returns-contracts"; import { normaliseContentScriptMessageResponse } from "./content-script-message-response"; @@ -36,12 +37,18 @@ import { persistFiledReturnsTargetDownloadId, persistFiledReturnsTargetDownloadIntent, } from "./filed-returns-target-download-attempt"; +import { clearFiledReturnsTargetReviewWithReason } from "./filed-returns-target-review"; +import { + FiledReturnsTargetReviewClearError, + filedReturnsTargetReviewClearFailureSignal, +} from "../connectors/gst/filed-returns-target-review-clear"; import { gstr3bFullFiscalYearAcquisitionNotWiredStep, isGstr3bFullFiscalYearAcquisitionScope, } from "./gstr3b-artifact-acquisition-block"; import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; import { persistSinglePeriodSummary } from "./filed-returns-single-period-summary"; +import { persistCanonicalFiledReturnsFlowSummary } from "./filed-returns-session-summary"; type FlowStepResponse = Extract; @@ -51,20 +58,16 @@ const DECLINED_ARTIFACT_SIGNAL_SET = new Set(DECLINED_ARTIFACT_SIGNALS); async function persistSingleArtifactRecoveryIntent( scope: FiledReturnsDownloadScope, - artifactType: FiledReturnsConcreteArtifactType, - actionId: string, + intent: Extract< + FiledReturnsTargetDownloadAttempt, + { kind: "single-artifact"; phase: "download-intent-persisted" } + >, deps: FiledReturnsFlowMessagingDeps, ): Promise { if (!deps.storageKeys.targetReview || deps.stageCapturedDownloads) return true; return persistFiledReturnsTargetDownloadIntent( - scope, - { - actionId, - artifactType, - kind: "single-artifact", - phase: "download-intent-persisted", - requestedAt: (deps.now?.() ?? new Date()).toISOString(), - }, + { ...scope, artifactType: intent.artifactType }, + intent, deps, ); } @@ -573,8 +576,18 @@ async function triggerPageGeneratedSinglePeriodArtifact( // OPFS staging has a separate durable bundle ledger. Only a browser-created // download needs this exact-ID checkpoint for recovery. const tracksBrowserDownload = !deps.stageCapturedDownloads; + const recoveryIntent = { + actionId: requestId, + artifactType, + kind: "single-artifact", + phase: "download-intent-persisted", + requestedAt: (deps.now?.() ?? new Date()).toISOString(), + } satisfies Extract< + FiledReturnsTargetDownloadAttempt, + { kind: "single-artifact"; phase: "download-intent-persisted" } + >; if (tracksBrowserDownload) { - if (!(await persistSingleArtifactRecoveryIntent(scope, artifactType, requestId, deps))) { + if (!(await persistSingleArtifactRecoveryIntent(scope, recoveryIntent, deps))) { return { ok: true, flowStep: { @@ -719,13 +732,68 @@ async function triggerPageGeneratedSinglePeriodArtifact( // checkpoint intact; only a confirmed durable terminal result permits its removal. retainCheckpointForRecovery = true; const completionKey = deps.storageKeys.completion; - const persisted = completionKey - ? await persistSinglePeriodSummary({ ...scope, artifactType }, declined.flowStep, { - storageKeys: { completion: completionKey }, - ...(deps.now ? { now: deps.now } : {}), - }) - : null; - if (persisted) retainCheckpointForRecovery = false; + const persisted = + tracksBrowserDownload && completionKey + ? await persistSinglePeriodSummary({ ...scope, artifactType }, declined.flowStep, { + storageKeys: { completion: completionKey }, + ...(deps.now ? { now: deps.now } : {}), + }) + : null; + if (persisted && completionKey) { + const targetScope = { ...scope, artifactType }; + let reviewClear: { ok: true } | { error: FiledReturnsTargetReviewClearError; ok: false }; + try { + reviewClear = deps.storageKeys.targetReview + ? await clearFiledReturnsTargetReviewWithReason( + targetScope, + deps, + undefined, + recoveryIntent, + ) + : { ok: true }; + } catch { + reviewClear = { + error: new FiledReturnsTargetReviewClearError("storage-read-failed"), + ok: false, + }; + } + if (!reviewClear.ok) { + const clearFailureStep: PortalFlowStepResult = { + ...declined.flowStep, + state: "download-unconfirmed", + safeSignals: [ + ...declined.flowStep.safeSignals, + "filed-returns-target-review-clear-failed", + filedReturnsTargetReviewClearFailureSignal(reviewClear.error.stage), + ], + safeMessage: + "Pack saved the terminal result but could not clear its saved recovery state.", + userAction: { + type: "RETRY_PORTAL_GENERATION", + message: "Retry so Pack can reconcile the saved target recovery checkpoint.", + canResume: true, + }, + }; + // Keep the original terminal-refusal proof on the returned step. The explicit blocked + // summary preserves that proof while preventing the outer boundary from reclassifying + // a retained cleanup failure as a completed absence. + const clearFailureSummary = await persistCanonicalFiledReturnsFlowSummary(completionKey, { + completedPeriods: [], + currentPeriod: scope.period, + flowStep: clearFailureStep, + scope: { ...scope, artifactType }, + status: "blocked", + totalPeriods: 1, + updatedAt: (deps.now?.() ?? new Date()).toISOString(), + }); + return { + ok: true, + flowStep: clearFailureStep, + ...(clearFailureSummary ? { flowSummary: clearFailureSummary } : {}), + }; + } + retainCheckpointForRecovery = false; + } return declined; } diff --git a/src/background/filed-returns-target-review.ts b/src/background/filed-returns-target-review.ts index 3ea24e7c..d911b0d5 100644 --- a/src/background/filed-returns-target-review.ts +++ b/src/background/filed-returns-target-review.ts @@ -282,8 +282,18 @@ export function clearFiledReturnsTargetReviewWithReason( scope: FiledReturnsDownloadScope, deps: FiledReturnsTargetReviewDeps, expectedRevision?: number, + expectedOwnedAttempt?: Extract< + FiledReturnsTargetDownloadAttempt, + { kind: "single-artifact"; phase: "download-intent-persisted" } + >, ): Promise { - return clearFiledReturnsTargetReviewAttempt(scope, deps, expectedRevision, "name-storage-errors"); + return clearFiledReturnsTargetReviewAttempt( + scope, + deps, + expectedRevision, + "name-storage-errors", + expectedOwnedAttempt, + ); } async function clearFiledReturnsTargetReviewAttempt( @@ -291,6 +301,10 @@ async function clearFiledReturnsTargetReviewAttempt( deps: FiledReturnsTargetReviewDeps, expectedRevision: number | undefined, storageErrorMode: "name-storage-errors" | "throw-storage-errors", + expectedOwnedAttempt?: Extract< + FiledReturnsTargetDownloadAttempt, + { kind: "single-artifact"; phase: "download-intent-persisted" } + >, ): Promise { const key = deps.storageKeys.targetReview; if (!key) return targetReviewClearFailure("storage-key-missing"); @@ -330,6 +344,12 @@ async function clearFiledReturnsTargetReviewAttempt( if (expectedRevision !== undefined && targetReviewRevision(state.review) !== expectedRevision) { return targetReviewClearFailure("revision-mismatch"); } + if ( + expectedOwnedAttempt && + !sameOwnedSingleArtifactIntent(state.review.downloadAttempt, expectedOwnedAttempt) + ) { + return targetReviewClearFailure("revision-mismatch"); + } try { await browser.storage.local.remove(key); } catch (error) { @@ -340,6 +360,22 @@ async function clearFiledReturnsTargetReviewAttempt( }); } +function sameOwnedSingleArtifactIntent( + actual: FiledReturnsTargetDownloadAttempt | undefined, + expected: Extract< + FiledReturnsTargetDownloadAttempt, + { kind: "single-artifact"; phase: "download-intent-persisted" } + >, +): boolean { + return ( + actual?.kind === "single-artifact" && + actual.phase === "download-intent-persisted" && + actual.actionId === expected.actionId && + actual.artifactType === expected.artifactType && + actual.requestedAt === expected.requestedAt + ); +} + function targetReviewClearFailure( stage: FiledReturnsTargetReviewClearFailureStage, ): FiledReturnsTargetReviewClearResult { From fa5a7b26db74f87883cbb62cc7b1b9d4cba1a475 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:07:05 +0530 Subject: [PATCH 24/48] test(gst): cover refusal cleanup ownership and failures --- ...turns-download-trigger-acquisition.test.ts | 296 +++++++++++++++++- .../filed-returns-target-review.test.ts | 92 ++++++ 2 files changed, 385 insertions(+), 3 deletions(-) diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 4372ecc9..d0f784a5 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -50,12 +50,25 @@ const captureMocks = vi.hoisted(() => ({ })), })); const summaryStorage = vi.hoisted(() => ({ + values: {} as Record, remove: vi.fn(async () => undefined), - set: vi.fn(async () => undefined), + set: vi.fn(async (values: Record) => { + Object.assign(summaryStorage.values, values); + }), +})); +const reviewStorage = vi.hoisted(() => ({ + values: {} as Record, + get: vi.fn(async (key: string) => ({ [key]: reviewStorage.values[key] })), + remove: vi.fn(async (key: string) => { + delete reviewStorage.values[key]; + }), + set: vi.fn(async (values: Record) => + Object.assign(reviewStorage.values, values), + ), })); vi.mock("wxt/browser", () => ({ - browser: { storage: { session: summaryStorage } }, + browser: { storage: { local: reviewStorage, session: summaryStorage } }, })); vi.mock("../../src/background/artifact-download", () => ({ @@ -95,6 +108,10 @@ import { import { withPersistedSinglePeriodSummary } from "../../src/background/filed-returns-single-period-summary"; import { FILED_RETURNS_RETURN_TYPES } from "../../src/connectors/gst/filed-returns-return-types"; import { filedReturnScopeId } from "../../src/connectors/gst/filed-returns-return-descriptors"; +import { + PACK_LOCAL_STORAGE_KEYS, + PACK_SESSION_STORAGE_KEYS, +} from "../../src/background/storage-keys"; const ARTIFACT_ACQUISITION_RETURN_TYPES = FILED_RETURNS_RETURN_TYPES; @@ -1057,11 +1074,26 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { connectorId: "gst", scopeId: filedReturnScopeId("GSTR-1"), state: "blocked", - safeSignals: ["filed-gstr1-excel-no-details-available"], + safeSignals: ["filed-gstr1-excel-no-details-available", "filed-gstr1-detail-period-verified"], safeMessage: "The GST Portal reported that no e-invoice details are available.", }, } as PackMessageResponse; + const gstr2bNotGeneratedStep = { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId("GSTR-2B"), + state: "blocked", + safeSignals: [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + safeMessage: "The GST Portal reported that this GSTR-2B is not generated.", + }, + } as PackMessageResponse; + function armDefinitiveNoActionFailure() { captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ ok: false as const, @@ -1121,6 +1153,262 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { ); }); + it("clears the exact persisted target review after a durable definitive refusal", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-1" } as const; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { + sendMessageToTabWithInjection: messagingDeps(noDetailsStep), + storageKeys: { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }, + }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "EXCEL" }, + response as Extract, + { + storageKeys: { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }, + } as never, + true, + ); + + expect(reviewStorage.set).toHaveBeenCalledWith( + expect.objectContaining({ [PACK_LOCAL_STORAGE_KEYS.targetReview]: expect.any(Object) }), + ); + expect(reviewStorage.remove).toHaveBeenCalledWith(PACK_LOCAL_STORAGE_KEYS.targetReview); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toBeUndefined(); + expect(persisted).toMatchObject({ flowSummary: { status: "complete" } }); + + armDefinitiveNoActionFailure(); + await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { + sendMessageToTabWithInjection: messagingDeps(noDetailsStep), + storageKeys: { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }, + }, + scope, + tabId: 17, + }); + expect(reviewStorage.set).toHaveBeenCalledTimes(2); + expect(reviewStorage.remove).toHaveBeenCalledTimes(2); + }); + + it("keeps a clear refusal blocked and resumable through outer summary persistence", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + reviewStorage.remove.mockRejectedValueOnce( + new Error("Synthetic target-review remove failure."), + ); + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-1" } as const; + const storageKeys = { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection: messagingDeps(noDetailsStep), storageKeys }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "EXCEL" }, + response as Extract, + { storageKeys } as never, + true, + ); + + expect(persisted).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "filed-gstr1-excel-no-details-available", + "filed-returns-target-review-clear-failed", + "filed-returns-target-review-clear-failed:storage-remove-failed", + ]), + state: "download-unconfirmed", + userAction: { canResume: true, type: "RETRY_PORTAL_GENERATION" }, + }, + flowSummary: { status: "blocked" }, + }); + expect( + summaryStorage.values[PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary], + ).toMatchObject({ + status: "blocked", + }); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toMatchObject({ + scope: { ...scope, artifactType: "EXCEL" }, + }); + }); + + it("keeps a target-review read throw blocked and resumable through outer summary persistence", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + reviewStorage.get + .mockImplementationOnce(async (key: string) => ({ [key]: reviewStorage.values[key] })) + .mockRejectedValueOnce(new Error("Synthetic target-review read failure.")); + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-1" } as const; + const storageKeys = { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection: messagingDeps(noDetailsStep), storageKeys }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "EXCEL" }, + response as Extract, + { storageKeys } as never, + true, + ); + + expect(persisted).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "filed-gstr1-excel-no-details-available", + "filed-returns-target-review-clear-failed:storage-read-failed", + ]), + userAction: { canResume: true }, + }, + flowSummary: { status: "blocked" }, + }); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toMatchObject({ + scope: { ...scope, artifactType: "EXCEL" }, + }); + }); + + it("keeps a GSTR-2B clear refusal blocked and resumable through outer summary persistence", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + reviewStorage.remove.mockRejectedValueOnce( + new Error("Synthetic target-review remove failure."), + ); + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-2B" } as const; + const storageKeys = { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "PDF", + deps: { sendMessageToTabWithInjection: messagingDeps(gstr2bNotGeneratedStep), storageKeys }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "PDF" }, + response as Extract, + { storageKeys } as never, + true, + ); + + expect(persisted).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "filed-gstr2b-not-generated", + "filed-returns-target-review-clear-failed:storage-remove-failed", + ]), + userAction: { canResume: true }, + }, + flowSummary: { status: "blocked" }, + }); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toMatchObject({ + scope: { ...scope, artifactType: "PDF" }, + }); + }); + + it("keeps a GSTR-2B target-review read throw blocked and resumable through outer summary persistence", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + reviewStorage.get + .mockImplementationOnce(async (key: string) => ({ [key]: reviewStorage.values[key] })) + .mockRejectedValueOnce(new Error("Synthetic target-review read failure.")); + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-2B" } as const; + const storageKeys = { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "PDF", + deps: { sendMessageToTabWithInjection: messagingDeps(gstr2bNotGeneratedStep), storageKeys }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "PDF" }, + response as Extract, + { storageKeys } as never, + true, + ); + + expect(persisted).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "filed-gstr2b-not-generated", + "filed-returns-target-review-clear-failed:storage-read-failed", + ]), + userAction: { canResume: true }, + }, + flowSummary: { status: "blocked" }, + }); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toMatchObject({ + scope: { ...scope, artifactType: "PDF" }, + }); + }); + + it("does not write a single-period summary for a staged full-year refusal", async () => { + armDefinitiveNoActionFailure(); + summaryStorage.set.mockRejectedValueOnce(new Error("Synthetic session write failure.")); + const persistedBefore = summaryStorage.set.mock.calls.length; + + await expect( + triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { + sendMessageToTabWithInjection: messagingDeps(noDetailsStep), + stageCapturedDownloads: { + bundleKind: "full-fiscal-year", + ledgerId: "full-fiscal-year:test", + }, + storageKeys: { completion: "completion" }, + }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }), + ).resolves.toMatchObject({ flowStep: { state: "blocked" } }); + expect(summaryStorage.set.mock.calls.length).toBe(persistedBefore); + summaryStorage.set.mockResolvedValue(undefined); + }); + it("retains an uncertain acquisition instead of adopting a later decline", async () => { captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ ok: false as const, @@ -1157,6 +1445,8 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { }); it("retains the checkpoint when terminal-decline persistence throws", async () => { + summaryStorage.set.mockReset(); + summaryStorage.set.mockResolvedValue(undefined); armDefinitiveNoActionFailure(); const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); const clearedBefore = captureMocks.clearArtifactAcquisitionCheckpoint.mock.calls.length; diff --git a/tests/background/filed-returns-target-review.test.ts b/tests/background/filed-returns-target-review.test.ts index e58b9cab..50bf51de 100644 --- a/tests/background/filed-returns-target-review.test.ts +++ b/tests/background/filed-returns-target-review.test.ts @@ -213,6 +213,98 @@ describe("filed returns target review", () => { }, ); + it.each([ + ["malformed", { schemaVersion: "1.0", unsafe: true }, "review-malformed"], + [ + "different scope", + { + downloadAttempt: { + actionId: "00000000-0000-4000-8000-000000000111", + artifactType: "EXCEL", + kind: "single-artifact", + phase: "download-intent-persisted", + requestedAt: "2026-06-24T00:00:00.000Z", + }, + revision: 1, + safeMessage: "Synthetic target review.", + safeSignals: ["browser-download-not-observed"], + schemaVersion: "1.0", + scope: { + artifactType: "EXCEL", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }, + status: "download-unconfirmed", + targetId: "GSTR-1:2025-26:April:EXCEL", + updatedAt: "2026-06-24T00:00:00.000Z", + }, + "scope-mismatch", + ], + ] as const)( + "does not clear an expected owned intent when the current review is %s", + async (_name, stored, stage) => { + const scope = { financialYear: "2025-26", period: "March", returnType: "GSTR-1" } as const; + browserMocks.storage.local.get.mockResolvedValue({ "target-review": stored }); + + const result = await clearFiledReturnsTargetReviewWithReason( + scope, + { storageKeys: { targetReview: "target-review" } }, + undefined, + { + actionId: "00000000-0000-4000-8000-000000000111", + artifactType: "EXCEL", + kind: "single-artifact", + phase: "download-intent-persisted", + requestedAt: "2026-06-24T00:00:00.000Z", + }, + ); + + expect(result).toMatchObject({ error: { stage }, ok: false }); + expect(browserMocks.storage.local.remove).not.toHaveBeenCalled(); + }, + ); + + it("does not clear an ABA-recreated same-revision review with a different owned intent", async () => { + const scope = { financialYear: "2025-26", period: "March", returnType: "GSTR-1" } as const; + const targetScope = { ...scope, artifactType: "EXCEL" } as const; + browserMocks.storage.local.get.mockResolvedValue({ + "target-review": { + downloadAttempt: { + actionId: "00000000-0000-4000-8000-000000000222", + artifactType: "EXCEL", + kind: "single-artifact", + phase: "download-intent-persisted", + requestedAt: "2026-06-24T00:00:01.000Z", + }, + revision: 1, + safeMessage: "Synthetic target review.", + safeSignals: ["browser-download-not-observed"], + schemaVersion: "1.0", + scope: { ...scope, artifactType: "EXCEL" }, + status: "download-unconfirmed", + targetId: "GSTR-1:2025-26:March:EXCEL", + updatedAt: "2026-06-24T00:00:01.000Z", + }, + }); + + const result = await clearFiledReturnsTargetReviewWithReason( + targetScope, + { storageKeys: { targetReview: "target-review" } }, + 1, + { + actionId: "00000000-0000-4000-8000-000000000111", + artifactType: "EXCEL", + kind: "single-artifact", + phase: "download-intent-persisted", + requestedAt: "2026-06-24T00:00:00.000Z", + }, + ); + + expect(result).toMatchObject({ error: { stage: "revision-mismatch" }, ok: false }); + expect(browserMocks.storage.local.remove).not.toHaveBeenCalled(); + }); + it("records a manual observation without completing or clearing the unresolved target", async () => { browserMocks.storage.local.get.mockImplementation(async (key: unknown) => key === "target-review" From 59ea23b9b91e149288babc178d98aa8c81925cd0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:39:46 +0530 Subject: [PATCH 25/48] fix(gst): require bound proof for durable not-generated --- src/connectors/gst/filed-returns-durable-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index c130ae34..d8a2deb3 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -123,7 +123,7 @@ export function parseDurableTargetStatus( if (!safeSignals) return null; if ( status === "not-generated" && - (scope.returnType !== "GSTR-2B" || !safeSignals.includes("filed-gstr2b-not-generated")) + getBoundDeclinedArtifactSignal(scope, safeSignals) !== "filed-gstr2b-not-generated" ) { return null; } From d12a2d202c31c273eec8e25272f5f213fff0e760 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:39:46 +0530 Subject: [PATCH 26/48] test(gst): prove bound full-year refusal persistence --- ...-supported-full-fiscal-year-ledger.test.ts | 54 ++++++++++++++++++- .../full-fiscal-year-ledger.test.ts | 31 +++++++++++ .../filed-returns-declined-artifact.test.ts | 9 +++- 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/tests/background/all-supported-full-fiscal-year-ledger.test.ts b/tests/background/all-supported-full-fiscal-year-ledger.test.ts index 963ba46a..0be3088a 100644 --- a/tests/background/all-supported-full-fiscal-year-ledger.test.ts +++ b/tests/background/all-supported-full-fiscal-year-ledger.test.ts @@ -73,6 +73,14 @@ function createReturnSpecificLedger(now = NOW) { ); } +function boundGstr2bNotGeneratedSignals() { + return [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]; +} + describe("all-supported full-fiscal-year ledger", () => { beforeEach(() => { stored.current = {}; @@ -633,7 +641,7 @@ describe("a period the portal declined to generate, in an all-returns year", () connectorId: "gst", scopeId: "gst-gstr2b-private-v0", state: "blocked", - safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeSignals: boundGstr2bNotGeneratedSignals(), safeMessage: "x", } as never, NOW, @@ -646,6 +654,50 @@ describe("a period the portal declined to generate, in an all-returns year", () expect(evidence?.outcome).not.toBe("needs-review"); }); + it("requires the bound route and visible-period proof for a stored not-generated target", () => { + const ledger = createLedger(); + const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); + if (!target) throw new Error("expected a GSTR-2B target in the all-returns plan"); + + for (const missingProof of [ + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]) { + const invalid = { + ...ledger, + targets: ledger.targets.map((candidate) => + candidate.targetId === target.targetId + ? { + ...candidate, + status: "not-generated" as const, + ...canonicalDurableTargetStatus( + candidate, + "not-generated", + boundGstr2bNotGeneratedSignals().filter((signal) => signal !== missingProof), + ), + } + : candidate, + ), + }; + expect(isAllSupportedFullFiscalYearLedger(invalid)).toBe(false); + } + + const boundSignals = boundGstr2bNotGeneratedSignals(); + const valid = { + ...ledger, + targets: ledger.targets.map((candidate) => + candidate.targetId === target.targetId + ? { + ...candidate, + status: "not-generated" as const, + ...canonicalDurableTargetStatus(candidate, "not-generated", boundSignals), + } + : candidate, + ), + }; + expect(isAllSupportedFullFiscalYearLedger(valid)).toBe(true); + }); + it("rejects a not-generated signal on a non-GSTR-2B stored target", () => { const ledger = createLedger(); const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-1"); diff --git a/tests/background/full-fiscal-year-ledger.test.ts b/tests/background/full-fiscal-year-ledger.test.ts index ca526756..b33a8ca0 100644 --- a/tests/background/full-fiscal-year-ledger.test.ts +++ b/tests/background/full-fiscal-year-ledger.test.ts @@ -38,6 +38,14 @@ import { isResolvedFullFiscalYearTargetStatus } from "../../src/connectors/gst/f import { FILED_RETURNS_FULL_FISCAL_YEAR_TARGET_STATUSES } from "../../src/connectors/gst/filed-returns-contracts"; import { isFiledReturnsFullFiscalYearTargetStatus } from "../../src/connectors/gst/filed-returns-contracts"; +function boundGstr2bNotGeneratedSignals() { + return [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]; +} + describe("full fiscal year ledger", () => { it("requires the canonical GSTR-2B all-formats artifact set before staging succeeds", () => { const expectedArtifacts = concreteFiledReturnsArtifactTypesForSelection( @@ -872,6 +880,29 @@ describe("full fiscal year ledger", () => { notFiledWithoutPositiveSignal.targets[0]!.safeSignals = []; expect(isFullFiscalYearLedger(notFiledWithoutPositiveSignal)).toBe(false); + for (const missingProof of [ + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]) { + const notGeneratedWithoutBinding = createLedger([["April", "not-generated"]], { + returnType: "GSTR-2B", + }); + notGeneratedWithoutBinding.targets[0]!.safeSignals = boundGstr2bNotGeneratedSignals().filter( + (signal) => signal !== missingProof, + ); + expect(isFullFiscalYearLedger(notGeneratedWithoutBinding)).toBe(false); + } + + const boundNotGenerated = createLedger([["April", "not-generated"]], { + returnType: "GSTR-2B", + }); + const boundSignals = boundGstr2bNotGeneratedSignals(); + boundNotGenerated.targets[0] = { + ...boundNotGenerated.targets[0]!, + ...canonicalDurableTargetStatus(boundNotGenerated.targets[0]!, "not-generated", boundSignals), + }; + expect(isFullFiscalYearLedger(boundNotGenerated)).toBe(true); + expect( isFullFiscalYearLedger({ ...createLedger([["April", "downloaded"]]), diff --git a/tests/connectors/filed-returns-declined-artifact.test.ts b/tests/connectors/filed-returns-declined-artifact.test.ts index 517e1b93..830db25c 100644 --- a/tests/connectors/filed-returns-declined-artifact.test.ts +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -15,7 +15,10 @@ import { FULL_FISCAL_YEAR_PERIOD } from "../../src/connectors/gst/filed-returns- import { parseDurableFiledReturnsSignals } from "../../src/connectors/gst/filed-returns-durable-signals"; import { createGstDocument, makeLayoutVisible } from "./filed-returns-flow.test-helpers"; import { normaliseText } from "../../src/connectors/gst/filed-returns-dom"; -import { canonicalDurableSummaryMessage } from "../../src/connectors/gst/filed-returns-durable-status"; +import { + canonicalDurableSummaryMessage, + parseDurableTargetStatus, +} from "../../src/connectors/gst/filed-returns-durable-status"; const SCOPE = { artifactType: "PDF_AND_EXCEL", @@ -149,6 +152,10 @@ describe("a declined artifact cannot be recorded unbound", () => { // A refusal is terminal, so its signals are persisted. One token the allowlist has never been // told about rejects the whole array, which is why the binding signals register by derivation. expect(parseDurableFiledReturnsSignals(step.safeSignals)).not.toBeNull(); + expect(parseDurableTargetStatus(SCOPE, "not-generated", step.safeSignals)).toMatchObject({ + safeSignals: step.safeSignals, + safeMessage: declinedArtifactSafeMessage("filed-gstr2b-not-generated"), + }); }); }); From 789d695804cd00a19e70beac50df728859f80801 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:49:36 +0530 Subject: [PATCH 27/48] fix(gst): retain owned review until checkpoint cleanup succeeds --- src/background/artifact-acquisition-state.ts | 91 +++++++++++++------ .../filed-returns-download-trigger.ts | 47 +++++++++- 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/background/artifact-acquisition-state.ts b/src/background/artifact-acquisition-state.ts index 8aca6c93..7d30cd04 100644 --- a/src/background/artifact-acquisition-state.ts +++ b/src/background/artifact-acquisition-state.ts @@ -45,6 +45,8 @@ const ARTIFACT_ACQUISITION_CHECKPOINT_BASE_KEYS = [ "state", ] as const; +let artifactAcquisitionCheckpointMutationCriticalSection = Promise.resolve(); + export type ArtifactAcquisitionTarget = Pick< FiledReturnsDownloadScope, "artifactType" | "financialYear" | "period" | "returnType" @@ -182,52 +184,85 @@ export async function clearMalformedArtifactAcquisitionCheckpoint( export async function persistArtifactAcquisitionIntent( input: Omit, ): Promise { - const key = artifactAcquisitionCheckpointKey(input); - await browser.storage.session.set({ - [key]: { - ...input, - armedAt: new Date().toISOString(), - state: "intent", - } satisfies ArtifactAcquisitionCheckpoint, + await runArtifactAcquisitionCheckpointMutation(async () => { + const key = artifactAcquisitionCheckpointKey(input); + await browser.storage.session.set({ + [key]: { + ...input, + armedAt: new Date().toISOString(), + state: "intent", + } satisfies ArtifactAcquisitionCheckpoint, + }); }); } export async function persistArtifactAcquisitionDownloadId( input: Omit, ): Promise { - const key = artifactAcquisitionCheckpointKey(input); - const stored = await browser.storage.session.get(key); - await browser.storage.session.set({ - [key]: { - ...input, - armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(), - state: "download-observing", - } satisfies ArtifactAcquisitionCheckpoint, + await runArtifactAcquisitionCheckpointMutation(async () => { + const key = artifactAcquisitionCheckpointKey(input); + const stored = await browser.storage.session.get(key); + await browser.storage.session.set({ + [key]: { + ...input, + armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(), + state: "download-observing", + } satisfies ArtifactAcquisitionCheckpoint, + }); }); } export async function persistArtifactAcquisitionUnconfirmedDownload( input: Omit, ): Promise { - const key = artifactAcquisitionCheckpointKey(input); - const stored = await browser.storage.session.get(key); - await browser.storage.session.set({ - [key]: { - ...input, - armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(), - state: "download-unconfirmed", - } satisfies ArtifactAcquisitionCheckpoint, + await runArtifactAcquisitionCheckpointMutation(async () => { + const key = artifactAcquisitionCheckpointKey(input); + const stored = await browser.storage.session.get(key); + await browser.storage.session.set({ + [key]: { + ...input, + armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(), + state: "download-unconfirmed", + } satisfies ArtifactAcquisitionCheckpoint, + }); }); } export async function clearArtifactAcquisitionCheckpoint( target: ArtifactAcquisitionTarget, requestId: string, -): Promise { - const key = artifactAcquisitionCheckpointKey(target); - const stored = await browser.storage.session.get(key); - if ((stored[key] as { requestId?: unknown } | undefined)?.requestId === requestId) { - await browser.storage.session.remove(key); +): Promise<{ ok: true } | { ok: false; reason: ArtifactAcquisitionCheckpointClearFailureReason }> { + return runArtifactAcquisitionCheckpointMutation(async () => { + const key = artifactAcquisitionCheckpointKey(target); + let stored: Record; + try { + stored = await browser.storage.session.get(key); + } catch { + return { ok: false, reason: "storage-read-failed" }; + } + if ((stored[key] as { requestId?: unknown } | undefined)?.requestId !== requestId) { + return { ok: false, reason: "checkpoint-invalid" }; + } + try { + await browser.storage.session.remove(key); + } catch { + return { ok: false, reason: "storage-remove-failed" }; + } + return { ok: true }; + }); +} + +async function runArtifactAcquisitionCheckpointMutation(action: () => Promise): Promise { + const previous = artifactAcquisitionCheckpointMutationCriticalSection; + let release: () => void = () => undefined; + artifactAcquisitionCheckpointMutationCriticalSection = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await action(); + } finally { + release(); } } diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 16ec14a4..ef9e2798 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -49,6 +49,7 @@ import { import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; import { persistSinglePeriodSummary } from "./filed-returns-single-period-summary"; import { persistCanonicalFiledReturnsFlowSummary } from "./filed-returns-session-summary"; +import { artifactAcquisitionCheckpointClearFailureSignal } from "../connectors/gst/artifact-acquisition-checkpoint-clear"; type FlowStepResponse = Extract; @@ -606,6 +607,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( let externallyVisibleActionMayHaveOccurred = artifact.state === "ready" && (artifactType === "PDF" || artifactType === "EXCEL"); let retainCheckpointForRecovery = false; + let checkpointCleared = false; try { const callbacks = { onStarted: async (downloadId: number) => { @@ -741,6 +743,44 @@ async function triggerPageGeneratedSinglePeriodArtifact( : null; if (persisted && completionKey) { const targetScope = { ...scope, artifactType }; + const checkpointClear = await clearArtifactAcquisitionCheckpoint( + checkpointTarget, + requestId, + ); + if (!checkpointClear.ok) { + const clearFailureStep: PortalFlowStepResult = { + ...declined.flowStep, + state: "download-unconfirmed", + safeSignals: [ + ...declined.flowStep.safeSignals, + "filed-returns-target-review-clear-failed", + artifactAcquisitionCheckpointClearFailureSignal(checkpointClear.reason), + ], + safeMessage: + "Pack saved the terminal result but could not clear its saved recovery checkpoint.", + userAction: { + type: "RETRY_PORTAL_GENERATION", + message: + "Review or cancel the saved target recovery checkpoint before starting another download.", + canResume: false, + }, + }; + const clearFailureSummary = await persistCanonicalFiledReturnsFlowSummary(completionKey, { + completedPeriods: [], + currentPeriod: scope.period, + flowStep: clearFailureStep, + scope: targetScope, + status: "blocked", + totalPeriods: 1, + updatedAt: (deps.now?.() ?? new Date()).toISOString(), + }); + return { + ok: true, + flowStep: clearFailureStep, + ...(clearFailureSummary ? { flowSummary: clearFailureSummary } : {}), + }; + } + checkpointCleared = true; let reviewClear: { ok: true } | { error: FiledReturnsTargetReviewClearError; ok: false }; try { reviewClear = deps.storageKeys.targetReview @@ -770,8 +810,9 @@ async function triggerPageGeneratedSinglePeriodArtifact( "Pack saved the terminal result but could not clear its saved recovery state.", userAction: { type: "RETRY_PORTAL_GENERATION", - message: "Retry so Pack can reconcile the saved target recovery checkpoint.", - canResume: true, + message: + "Review or cancel the saved target recovery checkpoint before starting another download.", + canResume: false, }, }; // Keep the original terminal-refusal proof on the returned step. The explicit blocked @@ -812,7 +853,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( }, }; } finally { - if (tracksBrowserDownload && !retainCheckpointForRecovery) { + if (tracksBrowserDownload && !retainCheckpointForRecovery && !checkpointCleared) { await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); } } From 7e579b988e569e3a09c252567590a9520367f776 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 00:49:36 +0530 Subject: [PATCH 28/48] test(gst): cover checkpoint cleanup races and bound refusal recovery --- .../artifact-acquisition-state.test.ts | 24 ++++++++ ...turns-download-trigger-acquisition.test.ts | 55 +++++++++++++++++-- ...scal-year-not-generated-round-trip.test.ts | 7 ++- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/tests/background/artifact-acquisition-state.test.ts b/tests/background/artifact-acquisition-state.test.ts index c1c62d39..e05957a4 100644 --- a/tests/background/artifact-acquisition-state.test.ts +++ b/tests/background/artifact-acquisition-state.test.ts @@ -668,6 +668,30 @@ describe("artifact acquisition checkpoint", () => { ); }); + it("does not let an old exact clear delete a newer same-target intent", async () => { + const key = artifactAcquisitionCheckpointKey(MAY_PDF); + await persistArtifactAcquisitionIntent({ ...MAY_PDF, requestId: actionId(20) }); + let releaseRead: (() => void) | undefined; + const readStarted = new Promise((resolve) => { + mocks.browser.storage.session.get.mockImplementationOnce(async (keys) => { + resolve(); + await new Promise((release) => { + releaseRead = release; + }); + return { [String(keys)]: mocks.session[String(keys)] }; + }); + }); + + const oldClear = clearArtifactAcquisitionCheckpoint(MAY_PDF, actionId(20)); + await readStarted; + const newIntent = persistArtifactAcquisitionIntent({ ...MAY_PDF, requestId: actionId(21) }); + releaseRead?.(); + + await expect(oldClear).resolves.toEqual({ ok: true }); + await newIntent; + expect(mocks.session[key]).toEqual(expect.objectContaining({ requestId: actionId(21) })); + }); + it("clears every concrete interrupted checkpoint when a composite target review is cancelled", async () => { await persistArtifactAcquisitionDownloadId({ ...MAY_COMPOSITE, diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index d0f784a5..5d35a108 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -41,7 +41,7 @@ const captureMocks = vi.hoisted(() => ({ safeMessage: undefined as string | undefined, safeSignals: [] as string[], })), - clearArtifactAcquisitionCheckpoint: vi.fn(async () => undefined), + clearArtifactAcquisitionCheckpoint: vi.fn(async () => ({ ok: true as const })), persistArtifactAcquisitionDownloadId: vi.fn(async () => undefined), persistArtifactAcquisitionIntent: vi.fn(async () => undefined), persistArtifactAcquisitionUnconfirmedDownload: vi.fn(async () => undefined), @@ -1244,9 +1244,9 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { "filed-returns-target-review-clear-failed:storage-remove-failed", ]), state: "download-unconfirmed", - userAction: { canResume: true, type: "RETRY_PORTAL_GENERATION" }, + userAction: { canResume: false, type: "RETRY_PORTAL_GENERATION" }, }, - flowSummary: { status: "blocked" }, + flowSummary: { flowStep: { userAction: { canResume: false } }, status: "blocked" }, }); expect( summaryStorage.values[PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary], @@ -1258,6 +1258,49 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { }); }); + it("retains the owned review when its exact acquisition checkpoint cannot be cleared", async () => { + armDefinitiveNoActionFailure(); + reviewStorage.values = {}; + summaryStorage.values = {}; + captureMocks.clearArtifactAcquisitionCheckpoint.mockResolvedValueOnce({ + ok: false as const, + reason: "storage-remove-failed" as const, + } as never); + const scope = { financialYear: "2025-26", period: "April", returnType: "GSTR-1" } as const; + const storageKeys = { + completion: PACK_SESSION_STORAGE_KEYS.lastFiledReturnsFlowSummary, + targetReview: PACK_LOCAL_STORAGE_KEYS.targetReview, + }; + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection: messagingDeps(noDetailsStep), storageKeys }, + scope, + tabId: 17, + }); + const persisted = await withPersistedSinglePeriodSummary( + { ...scope, artifactType: "EXCEL" }, + response as Extract, + { storageKeys } as never, + true, + ); + + expect(persisted).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "artifact-acquisition-checkpoint-clear-failed:storage-remove-failed", + ]), + userAction: { canResume: false }, + }, + flowSummary: { status: "blocked" }, + }); + expect(reviewStorage.values[PACK_LOCAL_STORAGE_KEYS.targetReview]).toMatchObject({ + scope: { ...scope, artifactType: "EXCEL" }, + }); + expect(reviewStorage.remove).not.toHaveBeenCalled(); + }); + it("keeps a target-review read throw blocked and resumable through outer summary persistence", async () => { armDefinitiveNoActionFailure(); reviewStorage.values = {}; @@ -1291,7 +1334,7 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { "filed-gstr1-excel-no-details-available", "filed-returns-target-review-clear-failed:storage-read-failed", ]), - userAction: { canResume: true }, + userAction: { canResume: false }, }, flowSummary: { status: "blocked" }, }); @@ -1333,7 +1376,7 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { "filed-gstr2b-not-generated", "filed-returns-target-review-clear-failed:storage-remove-failed", ]), - userAction: { canResume: true }, + userAction: { canResume: false }, }, flowSummary: { status: "blocked" }, }); @@ -1375,7 +1418,7 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { "filed-gstr2b-not-generated", "filed-returns-target-review-clear-failed:storage-read-failed", ]), - userAction: { canResume: true }, + userAction: { canResume: false }, }, flowSummary: { status: "blocked" }, }); 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 66a07e1b..70a3cc8d 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 @@ -53,7 +53,12 @@ function notGeneratedStep() { connectorId: "gst" as const, scopeId: "gst-gstr2b-private-v0", state: "blocked" as const, - safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"], + safeSignals: [ + "gstr2b-summary-route", + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], safeMessage: declinedArtifactSafeMessage("filed-gstr2b-not-generated"), }; } From ffe16b3fd5b87ad8e5e5bdbe556e319ced8bda99 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:28:13 +0530 Subject: [PATCH 29/48] fix(gst): surface ordinary checkpoint cleanup failures --- src/background/artifact-acquisition-state.ts | 11 +++++++++++ src/background/filed-returns-download-trigger.ts | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/background/artifact-acquisition-state.ts b/src/background/artifact-acquisition-state.ts index 7d30cd04..79a8385f 100644 --- a/src/background/artifact-acquisition-state.ts +++ b/src/background/artifact-acquisition-state.ts @@ -252,6 +252,17 @@ export async function clearArtifactAcquisitionCheckpoint( }); } +/** Clears an ordinary acquisition checkpoint, preserving the prior rejecting contract. */ +export async function clearArtifactAcquisitionCheckpointOrThrow( + target: ArtifactAcquisitionTarget, + requestId: string, +): Promise { + const result = await clearArtifactAcquisitionCheckpoint(target, requestId); + if (!result.ok) { + throw new Error(`artifact acquisition checkpoint clear failed: ${result.reason}`); + } +} + async function runArtifactAcquisitionCheckpointMutation(action: () => Promise): Promise { const previous = artifactAcquisitionCheckpointMutationCriticalSection; let release: () => void = () => undefined; diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index ef9e2798..0c994730 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -29,6 +29,7 @@ import { import { withFiledReturnsDownloadDiagnostic } from "./filed-returns-download-diagnostics"; import { clearArtifactAcquisitionCheckpoint, + clearArtifactAcquisitionCheckpointOrThrow, persistArtifactAcquisitionDownloadId, persistArtifactAcquisitionIntent, persistArtifactAcquisitionUnconfirmedDownload, @@ -307,7 +308,7 @@ export async function triggerAndObserveFiledReturnDownload({ }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { - await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); + await clearArtifactAcquisitionCheckpointOrThrow(checkpointTarget, requestId); } } } @@ -425,7 +426,7 @@ export async function triggerAndObserveFiledReturnDownload({ }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { - await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); + await clearArtifactAcquisitionCheckpointOrThrow(checkpointTarget, requestId); } } } @@ -854,7 +855,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery && !checkpointCleared) { - await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); + await clearArtifactAcquisitionCheckpointOrThrow(checkpointTarget, requestId); } } } From 4926fdc4a9908708d6fe9947210a721d34491d79 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:28:13 +0530 Subject: [PATCH 30/48] test(gst): cover ordinary checkpoint cleanup failures --- ...turns-download-trigger-acquisition.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 5d35a108..47fd43bb 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -42,6 +42,7 @@ const captureMocks = vi.hoisted(() => ({ safeSignals: [] as string[], })), clearArtifactAcquisitionCheckpoint: vi.fn(async () => ({ ok: true as const })), + clearArtifactAcquisitionCheckpointOrThrow: vi.fn(async () => undefined), persistArtifactAcquisitionDownloadId: vi.fn(async () => undefined), persistArtifactAcquisitionIntent: vi.fn(async () => undefined), persistArtifactAcquisitionUnconfirmedDownload: vi.fn(async () => undefined), @@ -76,6 +77,7 @@ vi.mock("../../src/background/artifact-download", () => ({ })); vi.mock("../../src/background/artifact-acquisition-state", () => ({ clearArtifactAcquisitionCheckpoint: captureMocks.clearArtifactAcquisitionCheckpoint, + clearArtifactAcquisitionCheckpointOrThrow: captureMocks.clearArtifactAcquisitionCheckpointOrThrow, persistArtifactAcquisitionDownloadId: captureMocks.persistArtifactAcquisitionDownloadId, persistArtifactAcquisitionIntent: captureMocks.persistArtifactAcquisitionIntent, persistArtifactAcquisitionUnconfirmedDownload: @@ -157,6 +159,53 @@ describe("GSTR-3B artifact acquisition dispatch", () => { ); }); + it.each(["JSON", "PDF"] as const)( + "surfaces a named cleanup failure when %s checkpoint cleanup fails", + async (artifactType) => { + vi.clearAllMocks(); + captureMocks.clearArtifactAcquisitionCheckpointOrThrow.mockRejectedValueOnce( + new Error("artifact acquisition checkpoint clear failed: storage-remove-failed"), + ); + if (artifactType === "JSON") { + captureMocks.acquireFiledReturnJsonInMainWorld.mockResolvedValueOnce({ + ok: false as const, + reason: "generation-timeout" as const, + safeSignals: [], + } as never); + } else { + captureMocks.acquireGstr3bPdfAfterPreflight.mockResolvedValueOnce({ + ok: false as const, + reason: "control-not-found" as const, + safeSignals: [], + } as never); + } + const sendMessageToTabWithInjection = vi.fn( + async (_tabId: number, message: { payload: { requestId: string } }) => ({ + ok: true as const, + artifact: { + ok: true as const, + state: "ready" as const, + requestId: message.payload.requestId, + safeSignals: [], + }, + }), + ); + + await expect( + triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType, + deps: { + sendMessageToTabWithInjection: sendMessageToTabWithInjection as never, + storageKeys: {}, + }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-3B" }, + tabId: 17, + }), + ).rejects.toThrow("artifact acquisition checkpoint clear failed: storage-remove-failed"); + }, + ); + it.each(["PDF", "JSON"] as const)( "blocks raw full-year GSTR-3B %s acquisition before it can create an artifact download", async (artifactType) => { From 7825a90c86ceec25c573098c206b4bbdc2a57960 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:31:19 +0530 Subject: [PATCH 31/48] fix(gst): preserve ordinary checkpoint mismatch no-op --- src/background/artifact-acquisition-state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/background/artifact-acquisition-state.ts b/src/background/artifact-acquisition-state.ts index 79a8385f..30d694de 100644 --- a/src/background/artifact-acquisition-state.ts +++ b/src/background/artifact-acquisition-state.ts @@ -258,7 +258,7 @@ export async function clearArtifactAcquisitionCheckpointOrThrow( requestId: string, ): Promise { const result = await clearArtifactAcquisitionCheckpoint(target, requestId); - if (!result.ok) { + if (!result.ok && result.reason !== "checkpoint-invalid") { throw new Error(`artifact acquisition checkpoint clear failed: ${result.reason}`); } } From dfa0f3a8e2ac1499165bca69747e62ba0c2034ad Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:31:19 +0530 Subject: [PATCH 32/48] test(gst): cover checkpoint failure callers --- .../artifact-acquisition-state.test.ts | 30 +++++++++++++ ...turns-download-trigger-acquisition.test.ts | 43 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/background/artifact-acquisition-state.test.ts b/tests/background/artifact-acquisition-state.test.ts index e05957a4..00228280 100644 --- a/tests/background/artifact-acquisition-state.test.ts +++ b/tests/background/artifact-acquisition-state.test.ts @@ -29,6 +29,7 @@ import { PACK_ARTIFACT_ACQUISITION_KEY_PREFIX, artifactAcquisitionCheckpointKey, clearArtifactAcquisitionCheckpoint, + clearArtifactAcquisitionCheckpointOrThrow, clearArtifactAcquisitionCheckpoints, clearArtifactAcquisitionCheckpointsAfterPersistedSummary, clearMalformedArtifactAcquisitionCheckpoint, @@ -668,6 +669,35 @@ describe("artifact acquisition checkpoint", () => { ); }); + it("keeps ordinary cleanup a no-op for a missing or newer request checkpoint", async () => { + await expect( + clearArtifactAcquisitionCheckpointOrThrow(MAY_PDF, actionId(10)), + ).resolves.toBeUndefined(); + await persistArtifactAcquisitionIntent({ ...MAY_PDF, requestId: actionId(21) }); + await expect( + clearArtifactAcquisitionCheckpointOrThrow(MAY_PDF, actionId(20)), + ).resolves.toBeUndefined(); + expect(mocks.session[artifactAcquisitionCheckpointKey(MAY_PDF)]).toEqual( + expect.objectContaining({ requestId: actionId(21) }), + ); + }); + + it.each(["storage-read-failed", "storage-remove-failed"] as const)( + "rejects ordinary cleanup with only the bounded %s reason", + async (reason) => { + await persistArtifactAcquisitionIntent({ ...MAY_PDF, requestId: actionId(22) }); + if (reason === "storage-read-failed") { + mocks.browser.storage.session.get.mockRejectedValueOnce(new Error("raw storage detail")); + } else { + mocks.browser.storage.session.remove.mockRejectedValueOnce(new Error("raw storage detail")); + } + await expect( + clearArtifactAcquisitionCheckpointOrThrow(MAY_PDF, actionId(22)), + ).rejects.toThrow(`artifact acquisition checkpoint clear failed: ${reason}`); + expect(mocks.session[artifactAcquisitionCheckpointKey(MAY_PDF)]).toBeDefined(); + }, + ); + it("does not let an old exact clear delete a newer same-target intent", async () => { const key = artifactAcquisitionCheckpointKey(MAY_PDF); await persistArtifactAcquisitionIntent({ ...MAY_PDF, requestId: actionId(20) }); diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 47fd43bb..195eea1e 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -215,7 +215,10 @@ describe("GSTR-3B artifact acquisition dispatch", () => { const response = await triggerAndObserveFiledReturnDownload({ activePeriod: null, artifactType, - deps: { sendMessageToTabWithInjection, storageKeys: {} }, + deps: { + sendMessageToTabWithInjection: sendMessageToTabWithInjection as never, + storageKeys: {}, + }, scope: { financialYear: "2026-27", period: "ALL", returnType: "GSTR-3B" }, tabId: 17, }); @@ -823,6 +826,44 @@ describe("GSTR-2B artifact acquisition dispatch", () => { }, ); + it("surfaces a bounded read failure from the page-generated cleanup caller", async () => { + vi.clearAllMocks(); + captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ + ok: false as const, + reason: "control-not-found" as const, + safeSignals: [], + } as never); + captureMocks.clearArtifactAcquisitionCheckpointOrThrow.mockRejectedValueOnce( + new Error("artifact acquisition checkpoint clear failed: storage-read-failed"), + ); + const sendMessageToTabWithInjection = vi.fn( + async (_tabId: number, message: { payload: { requestId: string } }) => + ({ + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: message.payload.requestId, + safeSignals: [], + }, + }) as PackMessageResponse, + ); + + await expect( + triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "PDF", + deps: { + sendMessageToTabWithInjection: sendMessageToTabWithInjection as never, + storageKeys: {}, + }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-2B" }, + tabId: 17, + }), + ).rejects.toThrow("artifact acquisition checkpoint clear failed: storage-read-failed"); + expect(captureMocks.clearArtifactAcquisitionCheckpointOrThrow).toHaveBeenCalled(); + }); + it("writes GSTR-2B portal data with its data suffix", async () => { const response = await triggerAndObserveFiledReturnDownload({ activePeriod: "June", From 54cd39d9943d068e919072c8e4ea642419dcda46 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:33:26 +0530 Subject: [PATCH 33/48] test(gst): prove successful ordinary checkpoint cleanup --- tests/background/artifact-acquisition-state.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/background/artifact-acquisition-state.test.ts b/tests/background/artifact-acquisition-state.test.ts index 00228280..8ecf45c2 100644 --- a/tests/background/artifact-acquisition-state.test.ts +++ b/tests/background/artifact-acquisition-state.test.ts @@ -680,6 +680,10 @@ describe("artifact acquisition checkpoint", () => { expect(mocks.session[artifactAcquisitionCheckpointKey(MAY_PDF)]).toEqual( expect.objectContaining({ requestId: actionId(21) }), ); + await expect( + clearArtifactAcquisitionCheckpointOrThrow(MAY_PDF, actionId(21)), + ).resolves.toBeUndefined(); + expect(mocks.session[artifactAcquisitionCheckpointKey(MAY_PDF)]).toBeUndefined(); }); it.each(["storage-read-failed", "storage-remove-failed"] as const)( From d57151d82f4fd9e4aaae4c6055edd2c5bd0b3df0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:57:05 +0530 Subject: [PATCH 34/48] fix(gst): reject staged artifacts beside refusal --- ...turns-all-supported-full-fiscal-year-summary.ts | 11 +++++++++-- ...ns-all-supported-full-fiscal-year-validation.ts | 7 +++++++ .../filed-returns-full-fiscal-year-summary.ts | 12 +++++++++--- .../filed-returns-full-fiscal-year-validation.ts | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts index 4165624d..34633ee6 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts @@ -1,4 +1,7 @@ -import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; +import { + filedReturnsTargetOutcome, + hasRetainedFullFiscalYearArtifactEvidence, +} from "./filed-returns-full-fiscal-year-summary"; import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearTargetEvidence, @@ -285,6 +288,10 @@ function targetOutcome( target.status, zipDelivered, false, - target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")), + target.status === "not-generated" + ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) + : target.safeSignals.some((signal) => + signal.startsWith("filed-return-artifact-unavailable:"), + ), ); } 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 d3275e48..999e26b4 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 @@ -42,6 +42,7 @@ import { import { canonicalFullFiscalYearPlanPeriods, isCanonicalFullFiscalYearPeriodPlan, + hasDurableFullFiscalYearArtifactEvidence, } from "./filed-returns-full-fiscal-year-validation"; import { filedReturnsTargetStatusBehaviour } from "../connectors/gst/filed-returns-contracts"; @@ -500,6 +501,12 @@ function isTarget( verifiedTarget.safeSignals, ); if (!durableStatus) return false; + if ( + verifiedTarget.status === "not-generated" && + hasDurableFullFiscalYearArtifactEvidence(verifiedTarget.safeSignals) + ) { + return false; + } if ( verifiedTarget.safeMessage !== durableStatus.safeMessage && !isHistoricalDurableTargetMessage( diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index 4cc8c3fc..fe500235 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -21,6 +21,7 @@ import { import { filedReturnsScopeId } from "../connectors/gst/filed-returns-return-types"; import { parseDurableTargetStatus } from "../connectors/gst/filed-returns-durable-status"; import { isUnconfirmedBrowserDownloadSignal } from "./download-evidence-signals"; +import { hasDurableFullFiscalYearArtifactEvidence } from "./filed-returns-full-fiscal-year-validation"; import { canCompleteFullFiscalYearLedger, unplannedEligibleFullFiscalYearPeriods, @@ -152,6 +153,10 @@ const TARGET_OUTCOMES: Readonly< pending: "pending", }; +export function hasRetainedFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { + return hasDurableFullFiscalYearArtifactEvidence(signals); +} + export function targetStatusFromFlowStep( step: PortalFlowStepResult, returnType?: FiledReturnsReturnType, @@ -257,6 +262,7 @@ export function filedReturnsTargetOutcome( missedAnArtifact: boolean, ): FiledReturnsTargetOutcome { const outcome = TARGET_OUTCOMES[status]; + if (outcome === "not-generated" && missedAnArtifact) return "needs-review"; // `summariseFullFiscalYearLedger` reports an interrupted run as blocked while // leaving the current target's durable status at `running`. Nothing is // running after an MV3 worker interruption, and reading it as "In progress" @@ -392,9 +398,9 @@ function targetMissedAnArtifact(target: FiledReturnsFullFiscalYearTarget): boole // absence fails ledger validation, so this cannot be reached with a real // record. A malformed one throwing here is diagnosable; a malformed one // silently reading as saved is not. - return target.safeSignals.some((signal) => - signal.startsWith("filed-return-artifact-unavailable:"), - ); + return target.status === "not-generated" + ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) + : target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")); } export function toFullFiscalYearSummary( diff --git a/src/background/filed-returns-full-fiscal-year-validation.ts b/src/background/filed-returns-full-fiscal-year-validation.ts index 3c74236a..8ac85cc4 100644 --- a/src/background/filed-returns-full-fiscal-year-validation.ts +++ b/src/background/filed-returns-full-fiscal-year-validation.ts @@ -116,6 +116,14 @@ export function durableFullFiscalYearArtifactSignals(signals: readonly string[]) ); } +export function hasDurableFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { + return signals.some((signal) => + /^(?:filed-return-artifact-downloaded|full-fiscal-year-opfs-staged|all-supported-full-fiscal-year-opfs-staged):(?:PDF|JSON|EXCEL)$/.test( + signal, + ), + ); +} + const MAX_SAFE_MESSAGE_LENGTH = 500; const VALID_LEDGER_STATUSES = new Set([ "running", @@ -397,6 +405,12 @@ function isFullFiscalYearTarget( target.safeSignals, ); if (!durableStatus) return false; + if ( + target.status === "not-generated" && + hasDurableFullFiscalYearArtifactEvidence(target.safeSignals ?? []) + ) { + return false; + } if ( target.safeMessage !== durableStatus.safeMessage && !isHistoricalDurableTargetMessage( From dc6cd5f3e832140b8e467d0fed55e69c5fbe9013 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 01:57:05 +0530 Subject: [PATCH 35/48] test(gst): retain staged refusal evidence --- ...-supported-full-fiscal-year-ledger.test.ts | 28 +++++++++++++++++++ .../filed-returns-target-evidence.test.ts | 14 ++++++++++ ...scal-year-not-generated-round-trip.test.ts | 19 +++++++++++++ 3 files changed, 61 insertions(+) diff --git a/tests/background/all-supported-full-fiscal-year-ledger.test.ts b/tests/background/all-supported-full-fiscal-year-ledger.test.ts index 0be3088a..abd4d8a1 100644 --- a/tests/background/all-supported-full-fiscal-year-ledger.test.ts +++ b/tests/background/all-supported-full-fiscal-year-ledger.test.ts @@ -654,6 +654,34 @@ describe("a period the portal declined to generate, in an all-returns year", () expect(evidence?.outcome).not.toBe("needs-review"); }); + it("keeps a bound refusal under review when staged artifact evidence remains", () => { + const ledger = createLedger(); + const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); + if (!target) throw new Error("expected a GSTR-2B target in the all-returns plan"); + const staged = { + ...ledger, + targets: ledger.targets.map((candidate) => + candidate.targetId === target.targetId + ? { + ...candidate, + status: "not-generated" as const, + ...canonicalDurableTargetStatus(candidate, "not-generated", [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + "full-fiscal-year-opfs-staged:PDF", + ]), + } + : candidate, + ), + }; + + const summary = toAllSupportedFullFiscalYearSummary(staged); + expect(summary.targetEvidence.find((row) => row.targetId === target.targetId)?.outcome).toBe( + "needs-review", + ); + }); + it("requires the bound route and visible-period proof for a stored not-generated target", () => { const ledger = createLedger(); const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); diff --git a/tests/background/filed-returns-target-evidence.test.ts b/tests/background/filed-returns-target-evidence.test.ts index 0a1a4ddd..5800a165 100644 --- a/tests/background/filed-returns-target-evidence.test.ts +++ b/tests/background/filed-returns-target-evidence.test.ts @@ -129,6 +129,20 @@ describe("per-target evidence in the flow summary", () => { expect(delivered.targetEvidence?.map((entry) => entry.outcome)).toEqual(["saved", "saved"]); }); + it("does not resolve a bound refusal when staged artifact evidence remains", () => { + const ledger = ledgerWith(["not-generated"]); + ledger.targets[0]!.safeSignals = [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + "full-fiscal-year-opfs-staged:PDF", + ]; + + const summary = toFullFiscalYearSummary(ledger, FLOW_STEP); + + expect(summary.targetEvidence).toEqual([{ period: "April", outcome: "needs-review" }]); + }); + // An interrupted run leaves the current target's durable status at `running` // while the ledger reports blocked. Nothing is running, so reading it as in // progress both misdescribes it and hides it from the needs-review count. 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 70a3cc8d..117c36fa 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 @@ -33,6 +33,7 @@ import { } from "../../src/background/filed-returns-full-fiscal-year-ledger"; import { exportFullFiscalYearZip } from "../../src/background/filed-returns-full-fiscal-year-zip"; import { declinedArtifactSafeMessage } from "../../src/connectors/gst/filed-returns-declined-artifact"; +import { isFullFiscalYearLedger } from "../../src/background/filed-returns-full-fiscal-year-validation"; import type { FiledReturnsDownloadScope } from "../../src/connectors/gst/filed-returns-contracts"; const deps = { @@ -97,6 +98,24 @@ describe("a full-year run whose period the portal never generated", () => { await expect(persistLedger(deps, ledger)).resolves.toBeUndefined(); }); + it("rejects a not-generated period that still retains staged artifact evidence", () => { + const ledger = createFullFiscalYearLedger(scope, new Date("2026-09-10T00:00:00.000Z"), [ + "April", + ]); + ledger.targets[0] = { + ...ledger.targets[0]!, + status: "not-generated", + safeSignals: [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + "full-fiscal-year-opfs-staged:PDF", + ], + safeMessage: declinedArtifactSafeMessage("filed-gstr2b-not-generated"), + }; + expect(isFullFiscalYearLedger(ledger)).toBe(false); + }); + 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"]); From 437d3ed84534efb26951feb8e08fa164e76881c1 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:05:30 +0530 Subject: [PATCH 36/48] fix(full-year): block retained artifacts on bound refusals --- ...-all-supported-full-fiscal-year-summary.ts | 8 +-- ...l-supported-full-fiscal-year-validation.ts | 23 +++++++ ...-returns-all-supported-full-fiscal-year.ts | 19 +++--- .../filed-returns-full-fiscal-year-summary.ts | 34 ++++++++-- ...led-returns-full-fiscal-year-validation.ts | 8 +-- .../filed-returns-full-fiscal-year.ts | 10 +-- ...rns-all-supported-full-fiscal-year.test.ts | 62 +++++++++++++++++++ 7 files changed, 137 insertions(+), 27 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts index 34633ee6..eaeca765 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts @@ -1,7 +1,5 @@ -import { - filedReturnsTargetOutcome, - hasRetainedFullFiscalYearArtifactEvidence, -} from "./filed-returns-full-fiscal-year-summary"; +import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; +import { hasDurableAllSupportedFullFiscalYearArtifactEvidence } from "./filed-returns-all-supported-full-fiscal-year-validation"; import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearTargetEvidence, @@ -289,7 +287,7 @@ function targetOutcome( zipDelivered, false, target.status === "not-generated" - ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) + ? hasDurableAllSupportedFullFiscalYearArtifactEvidence(target.safeSignals) : target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:"), ), 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 999e26b4..dd5b0acf 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 @@ -125,6 +125,29 @@ export type AllSupportedFullFiscalYearZipPhase = | "cleaned"; const MAX_SAFE_MESSAGE_LENGTH = 500; + +export function durableAllSupportedFullFiscalYearArtifactSignals( + signals: readonly string[], +): string[] { + return signals.filter( + (signal) => + /^filed-return-artifact-(?:downloaded|unavailable):(?:PDF|JSON|EXCEL)$/.test(signal) || + /^all-supported-full-fiscal-year-opfs-staged:(?:PDF|JSON|EXCEL)$/.test(signal), + ); +} + +export function hasDurableAllSupportedFullFiscalYearArtifactEvidence( + signals: readonly string[], +): boolean { + return ( + hasDurableFullFiscalYearArtifactEvidence(signals) || + durableAllSupportedFullFiscalYearArtifactSignals(signals).some( + (signal) => + signal.startsWith("filed-return-artifact-downloaded:") || + signal.startsWith("all-supported-full-fiscal-year-opfs-staged:"), + ) + ); +} const ZIP_PHASES = new Set([ "export-pending", "export-retry-pending", 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 bb07e8de..6a2771ad 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year.ts @@ -46,12 +46,16 @@ import type { FiledReturnsAllSupportedFullFiscalYearPeriodPlan, FiledReturnsAllSupportedFullFiscalYearTarget, } from "./filed-returns-all-supported-full-fiscal-year-validation"; +import { durableAllSupportedFullFiscalYearArtifactSignals } from "./filed-returns-all-supported-full-fiscal-year-validation"; import { discardAllSupportedFullFiscalYearFiledReturnsZip, exportAllSupportedFullFiscalYearZip, reconcileAllSupportedFullFiscalYearZipDownload, } from "./filed-returns-all-supported-full-fiscal-year-zip"; -import { targetStatusFromFlowStep } from "./filed-returns-full-fiscal-year-summary"; +import { + targetStatusFromFlowStep, + fullFiscalYearTargetFlowStep, +} from "./filed-returns-full-fiscal-year-summary"; import { canonicalDurableTargetStatus } from "../connectors/gst/filed-returns-durable-status"; type AllSupportedRunnerDeps = FiledReturnsFlowRunnerDeps & { @@ -550,12 +554,13 @@ async function runAllSupportedFullFiscalYearTargets( systemErrorPredecessor, ), ); - const targetStatus = targetStatusFromFlowStep(flowStep, scope.returnType); + const terminalFlowStep = fullFiscalYearTargetFlowStep(flowStep, scope.returnType); + const targetStatus = targetStatusFromFlowStep(terminalFlowStep, scope.returnType); ledger = markAllSupportedFullFiscalYearTargetTerminal( ledger, nextTarget.targetId, targetStatus, - flowStep, + terminalFlowStep, deps.now?.() ?? new Date(), ); if (canCompleteAllSupportedFullFiscalYearLedger(ledger)) { @@ -567,7 +572,7 @@ async function runAllSupportedFullFiscalYearTargets( (target) => target.targetId === nextTarget.targetId, ); if (persistedTarget && isResolvedFullFiscalYearTargetStatus(persistedTarget.status)) continue; - return allSupportedResponse(deps, ledger, flowStep); + return allSupportedResponse(deps, ledger, terminalFlowStep); } } @@ -829,11 +834,7 @@ function mergeRetriedArtifactSignals( previousSignals: readonly string[], flowStep: PortalFlowStepResult, ): PortalFlowStepResult { - const retained = previousSignals.filter( - (signal) => - /^filed-return-artifact-(?:downloaded|unavailable):(?:PDF|JSON|EXCEL)$/.test(signal) || - /^all-supported-full-fiscal-year-opfs-staged:(?:PDF|JSON|EXCEL)$/.test(signal), - ); + const retained = durableAllSupportedFullFiscalYearArtifactSignals(previousSignals); return retained.length === 0 ? flowStep : { ...flowStep, safeSignals: Array.from(new Set([...retained, ...flowStep.safeSignals])) }; diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index fe500235..da796fb8 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -22,6 +22,7 @@ import { filedReturnsScopeId } from "../connectors/gst/filed-returns-return-type import { parseDurableTargetStatus } from "../connectors/gst/filed-returns-durable-status"; import { isUnconfirmedBrowserDownloadSignal } from "./download-evidence-signals"; import { hasDurableFullFiscalYearArtifactEvidence } from "./filed-returns-full-fiscal-year-validation"; +import { hasDurableAllSupportedFullFiscalYearArtifactEvidence } from "./filed-returns-all-supported-full-fiscal-year-validation"; import { canCompleteFullFiscalYearLedger, unplannedEligibleFullFiscalYearPeriods, @@ -153,10 +154,6 @@ const TARGET_OUTCOMES: Readonly< pending: "pending", }; -export function hasRetainedFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { - return hasDurableFullFiscalYearArtifactEvidence(signals); -} - export function targetStatusFromFlowStep( step: PortalFlowStepResult, returnType?: FiledReturnsReturnType, @@ -178,6 +175,12 @@ export function targetStatusFromFlowStep( signal === "filed-gstr2b-not-generated" || signal === "artifact-filed-gstr2b-not-generated", ) ) { + if ( + hasDurableFullFiscalYearArtifactEvidence(step.safeSignals) || + hasDurableAllSupportedFullFiscalYearArtifactEvidence(step.safeSignals) + ) { + return "blocked"; + } return "not-generated"; } if (step.safeSignals.some(isUnconfirmedBrowserDownloadSignal)) { @@ -195,6 +198,26 @@ export function targetStatusFromFlowStep( return "failed"; } +export function fullFiscalYearTargetFlowStep( + step: PortalFlowStepResult, + returnType?: FiledReturnsReturnType, +): PortalFlowStepResult { + if ( + targetStatusFromFlowStep(step, returnType) === "blocked" && + returnType === "GSTR-2B" && + (hasDurableFullFiscalYearArtifactEvidence(step.safeSignals) || + hasDurableAllSupportedFullFiscalYearArtifactEvidence(step.safeSignals)) + ) { + return { + ...step, + state: "blocked", + safeMessage: + "Pack retained a captured artifact while the GST Portal reported this whole target as not generated; the fiscal-year run is paused for review.", + }; + } + return step; +} + export function summariseFullFiscalYearLedger( ledger: FiledReturnsFullFiscalYearLedger, now = new Date(), @@ -399,7 +422,8 @@ function targetMissedAnArtifact(target: FiledReturnsFullFiscalYearTarget): boole // record. A malformed one throwing here is diagnosable; a malformed one // silently reading as saved is not. return target.status === "not-generated" - ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) + ? hasDurableFullFiscalYearArtifactEvidence(target.safeSignals) || + hasDurableAllSupportedFullFiscalYearArtifactEvidence(target.safeSignals) : target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")); } diff --git a/src/background/filed-returns-full-fiscal-year-validation.ts b/src/background/filed-returns-full-fiscal-year-validation.ts index 8ac85cc4..1ec8210f 100644 --- a/src/background/filed-returns-full-fiscal-year-validation.ts +++ b/src/background/filed-returns-full-fiscal-year-validation.ts @@ -117,10 +117,10 @@ export function durableFullFiscalYearArtifactSignals(signals: readonly string[]) } export function hasDurableFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { - return signals.some((signal) => - /^(?:filed-return-artifact-downloaded|full-fiscal-year-opfs-staged|all-supported-full-fiscal-year-opfs-staged):(?:PDF|JSON|EXCEL)$/.test( - signal, - ), + return durableFullFiscalYearArtifactSignals(signals).some( + (signal) => + signal.startsWith("filed-return-artifact-downloaded:") || + signal.startsWith("full-fiscal-year-opfs-staged:"), ); } diff --git a/src/background/filed-returns-full-fiscal-year.ts b/src/background/filed-returns-full-fiscal-year.ts index b10167e7..c5f10b29 100644 --- a/src/background/filed-returns-full-fiscal-year.ts +++ b/src/background/filed-returns-full-fiscal-year.ts @@ -31,6 +31,7 @@ import { hasLegacyRetainedStaging, summariseFullFiscalYearLedger, targetStatusFromFlowStep, + fullFiscalYearTargetFlowStep, toFullFiscalYearSummary, } from "./filed-returns-full-fiscal-year-summary"; import { @@ -428,12 +429,13 @@ export async function startFullFiscalYearDownloadFlow( systemErrorPredecessor, ), ); - const targetStatus = targetStatusFromFlowStep(flowStep, retryScope.returnType); + const terminalFlowStep = fullFiscalYearTargetFlowStep(flowStep, retryScope.returnType); + const targetStatus = targetStatusFromFlowStep(terminalFlowStep, retryScope.returnType); ledger = markFullFiscalYearTargetTerminal( ledger, nextTarget.targetId, targetStatus, - flowStep, + terminalFlowStep, deps.now?.() ?? new Date(), ); if ( @@ -442,10 +444,10 @@ export async function startFullFiscalYearDownloadFlow( ) { ledger = markFullFiscalYearZipPhase(ledger, deps.now?.() ?? new Date(), "export-pending"); } - await persistLedgerAndMaybeSummary(deps, ledger, flowStep); + await persistLedgerAndMaybeSummary(deps, ledger, terminalFlowStep); if (isResolvedFullFiscalYearTargetStatus(targetStatus)) continue; - const flowSummary = toFullFiscalYearSummary(ledger, flowStep); + const flowSummary = toFullFiscalYearSummary(ledger, terminalFlowStep); if (targetStatus !== "download-unconfirmed") { await persistSummary(deps, flowSummary); } diff --git a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts index 69a2e9f9..f0b42a4d 100644 --- a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts +++ b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts @@ -690,6 +690,68 @@ describe("all-supported full-fiscal-year worker", () => { expect(savedLedger().revision).toBeGreaterThan(blocked.revision); }); + it("blocks a later bound GSTR-2B refusal after a staged artifact without exporting again", async () => { + const firstRunner = vi.fn(async (scope) => + scope.returnType === "GSTR-2B" + ? { + ok: true as const, + flowStep: { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "downloaded" as const, + safeSignals: [ + "filed-return-artifact-downloaded:PDF", + "all-supported-full-fiscal-year-opfs-staged:PDF", + ], + safeMessage: "Synthetic staged artifact.", + }, + } + : notFiledStep(), + ); + await startAllSupportedFullFiscalYearDownloadFlow(request, deps, firstRunner); + const checkpoint = savedLedger(); + const target = checkpoint.targets.find((candidate) => candidate.returnType === "GSTR-2B"); + if (!target) throw new Error("expected a GSTR-2B target"); + vi.clearAllMocks(); + const refusalRunner = vi.fn(async () => ({ + ok: true as const, + flowStep: { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "candidate-not-found" as const, + safeSignals: [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + safeMessage: "Synthetic bound refusal.", + }, + })); + + const response = await retryAllSupportedFullFiscalYearTarget( + { + financialYear: request.financialYear, + ledgerId: checkpoint.ledgerId, + targetId: target.targetId, + expectedRevision: checkpoint.revision, + }, + deps, + refusalRunner, + ); + + expect(response).toMatchObject({ flowStep: { state: "blocked" } }); + expect(response.flowStep.safeMessage).toContain("retained a captured artifact"); + expect(refusalRunner).toHaveBeenCalledOnce(); + expect(zip.export).not.toHaveBeenCalled(); + const retained = savedLedger(); + expect( + retained.targets.find((candidate) => candidate.targetId === target.targetId), + ).toMatchObject({ + status: "blocked", + safeSignals: expect.arrayContaining(["all-supported-full-fiscal-year-opfs-staged:PDF"]), + }); + }); + it("refuses a retry that names a different reviewed target", async () => { const blockedRunner = vi.fn(async () => blockedStep()); await startAllSupportedFullFiscalYearDownloadFlow(request, deps, blockedRunner); From f0f8ed370e4b751efb3f1b0238de646720d0b57c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:06:15 +0530 Subject: [PATCH 37/48] test(full-year): assert bounded refusal message --- .../filed-returns-all-supported-full-fiscal-year.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts index f0b42a4d..f1460c7c 100644 --- a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts +++ b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts @@ -740,7 +740,9 @@ describe("all-supported full-fiscal-year worker", () => { ); expect(response).toMatchObject({ flowStep: { state: "blocked" } }); - expect(response.flowStep.safeMessage).toContain("retained a captured artifact"); + expect("flowStep" in response ? response.flowStep.safeMessage : "").toContain( + "retained a captured artifact", + ); expect(refusalRunner).toHaveBeenCalledOnce(); expect(zip.export).not.toHaveBeenCalled(); const retained = savedLedger(); From f10cb7164941bda6974f90b3deb542ab4c14ee63 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:13:27 +0530 Subject: [PATCH 38/48] test(full-year): cover ordinary bound refusal recovery --- ...d-returns-all-supported-full-fiscal-year-ledger.ts | 2 +- ...turns-all-supported-full-fiscal-year-validation.ts | 2 +- .../filed-returns-full-fiscal-year-ledger.ts | 3 +++ .../filed-returns-full-fiscal-year-summary.ts | 5 +++++ src/background/filed-returns-full-fiscal-year.ts | 2 +- .../full-year-completion-fixtures.test-helpers.ts | 11 ++++++++--- 6 files changed, 19 insertions(+), 6 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 f8abd786..881e91c4 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 @@ -399,7 +399,7 @@ export function markAllSupportedFullFiscalYearTargetTerminal( ...target, status: effectiveStatus, ...canonicalDurableTargetStatus(targetScope(target), effectiveStatus, inputSignals), - ...(diagnosticState ?? {}), + ...(diagnosticState ?? {}), ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) ? { completedAt: timestamp } : {}), 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 dd5b0acf..ec7905b1 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 @@ -526,7 +526,7 @@ function isTarget( if (!durableStatus) return false; if ( verifiedTarget.status === "not-generated" && - hasDurableFullFiscalYearArtifactEvidence(verifiedTarget.safeSignals) + hasDurableAllSupportedFullFiscalYearArtifactEvidence(verifiedTarget.safeSignals) ) { return false; } diff --git a/src/background/filed-returns-full-fiscal-year-ledger.ts b/src/background/filed-returns-full-fiscal-year-ledger.ts index ecb3d03a..ae3de30e 100644 --- a/src/background/filed-returns-full-fiscal-year-ledger.ts +++ b/src/background/filed-returns-full-fiscal-year-ledger.ts @@ -344,6 +344,9 @@ export function markFullFiscalYearTargetTerminal( ["filed-return-durable-status-rejected"], )), ...(diagnosticState ?? {}), + ...(flowStep.safeMessage.includes("retained a captured artifact") + ? { safeMessage: flowStep.safeMessage } + : {}), ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) ? { completedAt: timestamp } : {}), updatedAt: timestamp, }; diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index da796fb8..2e6a1d97 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -205,6 +205,11 @@ export function fullFiscalYearTargetFlowStep( if ( targetStatusFromFlowStep(step, returnType) === "blocked" && returnType === "GSTR-2B" && + step.safeSignals.some( + (signal) => + signal === "filed-gstr2b-not-generated" || + signal === "artifact-filed-gstr2b-not-generated", + ) && (hasDurableFullFiscalYearArtifactEvidence(step.safeSignals) || hasDurableAllSupportedFullFiscalYearArtifactEvidence(step.safeSignals)) ) { diff --git a/src/background/filed-returns-full-fiscal-year.ts b/src/background/filed-returns-full-fiscal-year.ts index c5f10b29..e62de20d 100644 --- a/src/background/filed-returns-full-fiscal-year.ts +++ b/src/background/filed-returns-full-fiscal-year.ts @@ -451,7 +451,7 @@ export async function startFullFiscalYearDownloadFlow( if (targetStatus !== "download-unconfirmed") { await persistSummary(deps, flowSummary); } - return { ...response, flowStep, flowSummary }; + return { ...response, flowStep: terminalFlowStep, flowSummary }; } } diff --git a/tests/background/full-year-completion-fixtures.test-helpers.ts b/tests/background/full-year-completion-fixtures.test-helpers.ts index c59a9c32..d178bffe 100644 --- a/tests/background/full-year-completion-fixtures.test-helpers.ts +++ b/tests/background/full-year-completion-fixtures.test-helpers.ts @@ -35,10 +35,12 @@ export function makeCompletedRecoveryLedger( stagedPositive?: boolean; positiveFirst?: boolean; currentPositive?: boolean; + returnType?: "GSTR-1" | "GSTR-2B" | "GSTR-3B"; + stagedRecovery?: boolean; } = {}, ): FiledReturnsFullFiscalYearLedger { const ledger = createFullFiscalYearLedger( - RECOVERY_SCOPE, + { ...RECOVERY_SCOPE, returnType: options.returnType ?? RECOVERY_SCOPE.returnType }, new Date("2026-08-24T00:00:00.000Z"), FILED_RETURNS_MONTHS, ); @@ -52,7 +54,7 @@ export function makeCompletedRecoveryLedger( ? "downloaded" : "not-filed"; const signals = - targetStatus === "downloaded" + targetStatus === "downloaded" || (index === recoveryIndex && options.stagedRecovery) ? ["filed-return-artifact-downloaded:PDF", "full-fiscal-year-opfs-staged:PDF"] : targetStatus === "not-filed" ? ["filed-return-positively-not-filed"] @@ -72,7 +74,10 @@ export function makeCompletedRecoveryLedger( financialYear: target.financialYear, period: target.period, artifactType: "PDF" as const, - endpointClass: "gstr3b-portal-blob-captured-download" as const, + endpointClass: + target.returnType === "GSTR-2B" + ? ("gstr2b-portal-blob-captured-download" as const) + : ("gstr3b-portal-blob-captured-download" as const), downloadPathClass: "captured-portal-request-data" as const, status: "downloaded" as const, mimeClass: "pdf" as const, From 6ca0dd0a0f6bc706fdf16d197c15c73e67ed477b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:13:36 +0530 Subject: [PATCH 39/48] test(full-year): assert ordinary response checkpoint --- .../full-year-completion-start.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/background/full-year-completion-start.test.ts b/tests/background/full-year-completion-start.test.ts index 97dc057b..e17b064f 100644 --- a/tests/background/full-year-completion-start.test.ts +++ b/tests/background/full-year-completion-start.test.ts @@ -277,6 +277,58 @@ describe("full-year Start preserves existing recovery", () => { expect(runSinglePeriod).toHaveBeenCalledTimes(1); }); + it("blocks an ordinary active retry after staged evidence and does not export", async () => { + const scope = { ...RECOVERY_SCOPE, returnType: "GSTR-2B" as const }; + const ledger = makeCompletedRecoveryLedger("blocked", { + stagedPositive: true, + positiveFirst: true, + returnType: "GSTR-2B", + stagedRecovery: true, + }); + storage.local.ledger = ledger; + const target = ledger.targets[1]!; + const preparation = await prepareFullFiscalYearTargetRetry( + { ledgerId: ledger.ledgerId, targetId: target.targetId, expectedRevision: ledger.revision! }, + deps, + ); + expect(preparation.ok).toBe(true); + if (!preparation.ok) throw new Error("Expected retry preparation."); + storage.local.ledger = preparation.ledger; + const runSinglePeriod = vi.fn(async () => ({ + ok: true as const, + flowStep: { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "candidate-not-found" as const, + safeSignals: [ + "filed-gstr2b-not-generated", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + safeMessage: "Synthetic bound refusal.", + }, + })); + const response = await startFullFiscalYearDownloadFlow(scope, deps, runSinglePeriod, { + allowExistingLedgerResume: true, + }); + expect(response).toMatchObject({ flowSummary: { status: "blocked" } }); + expect(runSinglePeriod).toHaveBeenCalledOnce(); + expect(zipMocks.exportFullFiscalYearZip).not.toHaveBeenCalled(); + expect(storage.local.ledger).toMatchObject({ + status: "blocked", + targets: expect.arrayContaining([ + expect.objectContaining({ + status: "downloaded", + safeSignals: expect.arrayContaining(["full-fiscal-year-opfs-staged:PDF"]), + }), + expect.objectContaining({ + status: "blocked", + safeMessage: expect.stringContaining("retained a captured artifact"), + }), + ]), + }); + }); + it.each(["ledger", "target", "revision", "running"] as const)( "preserves the %s retry guard", async (guard) => { From e14c0b73555a567e0c4dfbf785d7c6718c4d3ba4 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:15:54 +0530 Subject: [PATCH 40/48] fix(full-year): canonicalize retained refusal recovery --- .../filed-returns-full-fiscal-year-ledger.ts | 3 --- .../filed-returns-full-fiscal-year-summary.ts | 8 +++++--- src/connectors/gst/filed-returns-durable-status.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/background/filed-returns-full-fiscal-year-ledger.ts b/src/background/filed-returns-full-fiscal-year-ledger.ts index ae3de30e..ecb3d03a 100644 --- a/src/background/filed-returns-full-fiscal-year-ledger.ts +++ b/src/background/filed-returns-full-fiscal-year-ledger.ts @@ -344,9 +344,6 @@ export function markFullFiscalYearTargetTerminal( ["filed-return-durable-status-rejected"], )), ...(diagnosticState ?? {}), - ...(flowStep.safeMessage.includes("retained a captured artifact") - ? { safeMessage: flowStep.safeMessage } - : {}), ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) ? { completedAt: timestamp } : {}), updatedAt: timestamp, }; diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index 2e6a1d97..e2a02dbc 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -19,7 +19,10 @@ import { normaliseFiledReturnsArtifactType, } from "../connectors/gst/filed-returns-artifacts"; import { filedReturnsScopeId } from "../connectors/gst/filed-returns-return-types"; -import { parseDurableTargetStatus } from "../connectors/gst/filed-returns-durable-status"; +import { + parseDurableTargetStatus, + DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE, +} from "../connectors/gst/filed-returns-durable-status"; import { isUnconfirmedBrowserDownloadSignal } from "./download-evidence-signals"; import { hasDurableFullFiscalYearArtifactEvidence } from "./filed-returns-full-fiscal-year-validation"; import { hasDurableAllSupportedFullFiscalYearArtifactEvidence } from "./filed-returns-all-supported-full-fiscal-year-validation"; @@ -216,8 +219,7 @@ export function fullFiscalYearTargetFlowStep( return { ...step, state: "blocked", - safeMessage: - "Pack retained a captured artifact while the GST Portal reported this whole target as not generated; the fiscal-year run is paused for review.", + safeMessage: DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE, }; } return step; diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index d8a2deb3..72904b13 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -278,6 +278,17 @@ function canonicalDurableTargetMessage( status: FiledReturnsFullFiscalYearTargetStatus | "target-review", signals: readonly string[], ): string { + if ( + status === "blocked" && + signals.some((signal) => signal === "filed-gstr2b-not-generated") && + signals.some( + (signal) => + signal.startsWith("filed-return-artifact-downloaded:") || + signal.includes("full-fiscal-year-opfs-staged:"), + ) + ) { + return DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE; + } return [ renderDurableMessage(messageKeyForTarget(status, signals), scope), filenameOutcomeMessage(signals, status === "downloaded" ? "download" : "unresolved-target"), @@ -286,6 +297,9 @@ function canonicalDurableTargetMessage( .join(" "); } +export const DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE = + "Pack retained a captured artifact while the GST Portal reported this whole target as not generated; the fiscal-year run is paused for review."; + function filenameOutcomeMessage( signals: readonly string[], context: "download" | "unresolved-target", From 8cc1a66cbfef6ce8044824a34ba3b122747d65aa Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:23:10 +0530 Subject: [PATCH 41/48] fix(full-year): share retained refusal classification and recovery --- ...-all-supported-full-fiscal-year-summary.ts | 28 +++++++++++++++---- ...l-supported-full-fiscal-year-validation.ts | 16 ++--------- ...-returns-all-supported-full-fiscal-year.ts | 23 +++------------ .../filed-returns-full-fiscal-year-summary.ts | 23 +++++---------- ...led-returns-full-fiscal-year-validation.ts | 11 ++------ .../gst/filed-returns-durable-signals.ts | 25 +++++++++++++++++ .../gst/filed-returns-durable-status.ts | 11 ++------ 7 files changed, 64 insertions(+), 73 deletions(-) diff --git a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts index eaeca765..c1315896 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year-summary.ts @@ -1,5 +1,9 @@ import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary"; -import { hasDurableAllSupportedFullFiscalYearArtifactEvidence } from "./filed-returns-all-supported-full-fiscal-year-validation"; +import { + hasRetainedFullFiscalYearArtifactEvidence, + hasFullFiscalYearRefusalArtifactConflict, +} from "../connectors/gst/filed-returns-durable-signals"; +import { canonicalDurableTargetStatus } from "../connectors/gst/filed-returns-durable-status"; import type { FiledReturnsAllSupportedFullFiscalYearFlowSummary, FiledReturnsAllSupportedFullFiscalYearTargetEvidence, @@ -244,13 +248,25 @@ function summaryStep( : "Pack confirmed the final fiscal-year ZIP download.", }; } + return unresolvedAllSupportedFullFiscalYearStep(ledger); +} + +export function unresolvedAllSupportedFullFiscalYearStep( + ledger: FiledReturnsAllSupportedFullFiscalYearLedger, +): PortalFlowStepResult { + const target = ledger.targets.find((item) => item.targetId === ledger.currentTargetId); + const conflict = + target?.status === "blocked" && + hasFullFiscalYearRefusalArtifactConflict(target.returnType, target.safeSignals); return { - connectorId, - scopeId, + connectorId: "gst", + scopeId: filedReturnScopeId((target ?? ledger.targets[0]!).returnType), state: "blocked", safeSignals: ["all-supported-full-fiscal-year-run-needs-action"], - safeMessage: - "Pack retained the saved fiscal-year plan and will not repeat unresolved portal targets.", + safeMessage: conflict + ? canonicalDurableTargetStatus(scopeForTarget(target), target.status, target.safeSignals) + .safeMessage + : "Pack retained the saved fiscal-year plan and will not repeat unresolved portal targets.", userAction: { type: "RETRY_PORTAL_GENERATION", message: "Resolve the saved fiscal-year plan before starting another one.", @@ -287,7 +303,7 @@ function targetOutcome( zipDelivered, false, target.status === "not-generated" - ? hasDurableAllSupportedFullFiscalYearArtifactEvidence(target.safeSignals) + ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) : target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:"), ), 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 ec7905b1..de8bbb0c 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 @@ -1,3 +1,4 @@ +import { hasRetainedFullFiscalYearArtifactEvidence } from "../connectors/gst/filed-returns-durable-signals"; import type { FiledReturnsAllSupportedFullFiscalYearIdentity, FiledReturnsDownloadDiagnostic, @@ -42,7 +43,6 @@ import { import { canonicalFullFiscalYearPlanPeriods, isCanonicalFullFiscalYearPeriodPlan, - hasDurableFullFiscalYearArtifactEvidence, } from "./filed-returns-full-fiscal-year-validation"; import { filedReturnsTargetStatusBehaviour } from "../connectors/gst/filed-returns-contracts"; @@ -136,18 +136,6 @@ export function durableAllSupportedFullFiscalYearArtifactSignals( ); } -export function hasDurableAllSupportedFullFiscalYearArtifactEvidence( - signals: readonly string[], -): boolean { - return ( - hasDurableFullFiscalYearArtifactEvidence(signals) || - durableAllSupportedFullFiscalYearArtifactSignals(signals).some( - (signal) => - signal.startsWith("filed-return-artifact-downloaded:") || - signal.startsWith("all-supported-full-fiscal-year-opfs-staged:"), - ) - ); -} const ZIP_PHASES = new Set([ "export-pending", "export-retry-pending", @@ -526,7 +514,7 @@ function isTarget( if (!durableStatus) return false; if ( verifiedTarget.status === "not-generated" && - hasDurableAllSupportedFullFiscalYearArtifactEvidence(verifiedTarget.safeSignals) + hasRetainedFullFiscalYearArtifactEvidence(verifiedTarget.safeSignals) ) { return false; } 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 6a2771ad..a2e028f9 100644 --- a/src/background/filed-returns-all-supported-full-fiscal-year.ts +++ b/src/background/filed-returns-all-supported-full-fiscal-year.ts @@ -33,7 +33,10 @@ import { markAllSupportedFullFiscalYearTargetTerminal, nextRunnableAllSupportedFullFiscalYearTarget, } from "./filed-returns-all-supported-full-fiscal-year-ledger"; -import { allSupportedTerminalPlanRoots } from "./filed-returns-all-supported-full-fiscal-year-summary"; +import { + unresolvedAllSupportedFullFiscalYearStep as unresolvedRunStep, + allSupportedTerminalPlanRoots, +} from "./filed-returns-all-supported-full-fiscal-year-summary"; import { readAllSupportedFullFiscalYearLedgerForPlanRoot, readAllSupportedPlanLedgersStorageState, @@ -977,24 +980,6 @@ function interruptedRunStep( }; } -function unresolvedRunStep( - ledger: FiledReturnsAllSupportedFullFiscalYearLedger, -): PortalFlowStepResult { - return { - connectorId: "gst", - scopeId: filedReturnScopeId(ledger.targets[0]!.returnType), - state: "blocked", - safeSignals: ["all-supported-full-fiscal-year-run-needs-action"], - safeMessage: - "Pack retained the saved fiscal-year plan and will not repeat unresolved portal targets.", - userAction: { - type: "RETRY_PORTAL_GENERATION", - message: "Resolve the saved fiscal-year plan before starting another one.", - canResume: true, - }, - }; -} - function finalZipReviewStep( ledger: FiledReturnsAllSupportedFullFiscalYearLedger, ): PortalFlowStepResult { diff --git a/src/background/filed-returns-full-fiscal-year-summary.ts b/src/background/filed-returns-full-fiscal-year-summary.ts index e2a02dbc..97ef9de1 100644 --- a/src/background/filed-returns-full-fiscal-year-summary.ts +++ b/src/background/filed-returns-full-fiscal-year-summary.ts @@ -1,3 +1,7 @@ +import { + hasRetainedFullFiscalYearArtifactEvidence, + hasFullFiscalYearRefusalArtifactConflict, +} from "../connectors/gst/filed-returns-durable-signals"; import type { FiledReturnsTargetEvidence, FiledReturnsTargetOutcome, @@ -24,8 +28,6 @@ import { DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE, } from "../connectors/gst/filed-returns-durable-status"; import { isUnconfirmedBrowserDownloadSignal } from "./download-evidence-signals"; -import { hasDurableFullFiscalYearArtifactEvidence } from "./filed-returns-full-fiscal-year-validation"; -import { hasDurableAllSupportedFullFiscalYearArtifactEvidence } from "./filed-returns-all-supported-full-fiscal-year-validation"; import { canCompleteFullFiscalYearLedger, unplannedEligibleFullFiscalYearPeriods, @@ -178,10 +180,7 @@ export function targetStatusFromFlowStep( signal === "filed-gstr2b-not-generated" || signal === "artifact-filed-gstr2b-not-generated", ) ) { - if ( - hasDurableFullFiscalYearArtifactEvidence(step.safeSignals) || - hasDurableAllSupportedFullFiscalYearArtifactEvidence(step.safeSignals) - ) { + if (hasFullFiscalYearRefusalArtifactConflict(returnType, step.safeSignals)) { return "blocked"; } return "not-generated"; @@ -207,14 +206,7 @@ export function fullFiscalYearTargetFlowStep( ): PortalFlowStepResult { if ( targetStatusFromFlowStep(step, returnType) === "blocked" && - returnType === "GSTR-2B" && - step.safeSignals.some( - (signal) => - signal === "filed-gstr2b-not-generated" || - signal === "artifact-filed-gstr2b-not-generated", - ) && - (hasDurableFullFiscalYearArtifactEvidence(step.safeSignals) || - hasDurableAllSupportedFullFiscalYearArtifactEvidence(step.safeSignals)) + hasFullFiscalYearRefusalArtifactConflict(returnType, step.safeSignals) ) { return { ...step, @@ -429,8 +421,7 @@ function targetMissedAnArtifact(target: FiledReturnsFullFiscalYearTarget): boole // record. A malformed one throwing here is diagnosable; a malformed one // silently reading as saved is not. return target.status === "not-generated" - ? hasDurableFullFiscalYearArtifactEvidence(target.safeSignals) || - hasDurableAllSupportedFullFiscalYearArtifactEvidence(target.safeSignals) + ? hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals) : target.safeSignals.some((signal) => signal.startsWith("filed-return-artifact-unavailable:")); } diff --git a/src/background/filed-returns-full-fiscal-year-validation.ts b/src/background/filed-returns-full-fiscal-year-validation.ts index 1ec8210f..abb5461b 100644 --- a/src/background/filed-returns-full-fiscal-year-validation.ts +++ b/src/background/filed-returns-full-fiscal-year-validation.ts @@ -1,3 +1,4 @@ +import { hasRetainedFullFiscalYearArtifactEvidence } from "../connectors/gst/filed-returns-durable-signals"; import type { FiledReturnsDownloadScope, FiledReturnsFullFiscalYearLedger, @@ -116,14 +117,6 @@ export function durableFullFiscalYearArtifactSignals(signals: readonly string[]) ); } -export function hasDurableFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { - return durableFullFiscalYearArtifactSignals(signals).some( - (signal) => - signal.startsWith("filed-return-artifact-downloaded:") || - signal.startsWith("full-fiscal-year-opfs-staged:"), - ); -} - const MAX_SAFE_MESSAGE_LENGTH = 500; const VALID_LEDGER_STATUSES = new Set([ "running", @@ -407,7 +400,7 @@ function isFullFiscalYearTarget( if (!durableStatus) return false; if ( target.status === "not-generated" && - hasDurableFullFiscalYearArtifactEvidence(target.safeSignals ?? []) + hasRetainedFullFiscalYearArtifactEvidence(target.safeSignals ?? []) ) { return false; } diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index f37f6c91..60128519 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -66,6 +66,31 @@ export function isUnconfirmedFiledReturnsDownloadSignal(signal: string): boolean return UNCONFIRMED_BROWSER_DOWNLOAD_SIGNALS.has(signal); } +/** Existing saved-artifact evidence, excluding legitimate unavailable formats. */ +export function hasRetainedFullFiscalYearArtifactEvidence(signals: readonly string[]): boolean { + return signals.some( + (signal) => + isDurableFiledReturnsSignal(signal) && + (signal.startsWith("filed-return-artifact-downloaded:") || + signal.startsWith("full-fiscal-year-opfs-staged:") || + signal.startsWith("all-supported-full-fiscal-year-opfs-staged:")), + ); +} + +export function hasFullFiscalYearRefusalArtifactConflict( + returnType: FiledReturnsReturnType | undefined, + signals: readonly string[], +): boolean { + return ( + returnType === "GSTR-2B" && + signals.some( + (signal) => + signal === "filed-gstr2b-not-generated" || signal === "artifact-filed-gstr2b-not-generated", + ) && + hasRetainedFullFiscalYearArtifactEvidence(signals) + ); +} + export type DurableFiledReturnsSignalRejectionReason = | "duplicate" | "non-string" diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index 72904b13..5def299e 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -32,6 +32,7 @@ import { FILED_RETURNS_FILENAME_UNAVAILABLE_SIGNALS, FILED_RETURN_ROUTE_MISMATCH_SIGNALS, RETURN_TYPE_MISMATCH_RECOVERY_STOPPED_SIGNAL, + hasFullFiscalYearRefusalArtifactConflict, durableFiledReturnsSignalRejectionReason, isUnconfirmedFiledReturnsDownloadSignal, parseDurableFiledReturnsSignals, @@ -278,15 +279,7 @@ function canonicalDurableTargetMessage( status: FiledReturnsFullFiscalYearTargetStatus | "target-review", signals: readonly string[], ): string { - if ( - status === "blocked" && - signals.some((signal) => signal === "filed-gstr2b-not-generated") && - signals.some( - (signal) => - signal.startsWith("filed-return-artifact-downloaded:") || - signal.includes("full-fiscal-year-opfs-staged:"), - ) - ) { + if (status === "blocked" && hasFullFiscalYearRefusalArtifactConflict(scope.returnType, signals)) { return DURABLE_BOUND_REFUSAL_WITH_RETAINED_ARTIFACT_MESSAGE; } return [ From 3028e81d4a531799657e18d94d8ee9ce48c256f9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:23:10 +0530 Subject: [PATCH 42/48] test(full-year): exercise fresh staged refusal and reopen paths --- ...-supported-full-fiscal-year-ledger.test.ts | 9 +- ...rns-all-supported-full-fiscal-year.test.ts | 67 +++++++++++++ ...scal-year-not-generated-round-trip.test.ts | 8 +- .../full-year-completion-start.test.ts | 93 ++++++++++++++++++- 4 files changed, 171 insertions(+), 6 deletions(-) diff --git a/tests/background/all-supported-full-fiscal-year-ledger.test.ts b/tests/background/all-supported-full-fiscal-year-ledger.test.ts index abd4d8a1..d52dd03f 100644 --- a/tests/background/all-supported-full-fiscal-year-ledger.test.ts +++ b/tests/background/all-supported-full-fiscal-year-ledger.test.ts @@ -654,7 +654,11 @@ describe("a period the portal declined to generate, in an all-returns year", () expect(evidence?.outcome).not.toBe("needs-review"); }); - it("keeps a bound refusal under review when staged artifact evidence remains", () => { + it.each([ + "full-fiscal-year-opfs-staged:PDF", + "all-supported-full-fiscal-year-opfs-staged:PDF", + "filed-return-artifact-downloaded:PDF", + ])("rejects a stored whole-target refusal retaining %s", (retainedSignal) => { const ledger = createLedger(); const target = ledger.targets.find((candidate) => candidate.returnType === "GSTR-2B"); if (!target) throw new Error("expected a GSTR-2B target in the all-returns plan"); @@ -669,13 +673,14 @@ describe("a period the portal declined to generate, in an all-returns year", () "filed-gstr2b-not-generated", "gstr2b-summary-route-verified", "gstr2b-visible-period-verified", - "full-fiscal-year-opfs-staged:PDF", + retainedSignal, ]), } : candidate, ), }; + expect(isAllSupportedFullFiscalYearLedger(staged)).toBe(false); const summary = toAllSupportedFullFiscalYearSummary(staged); expect(summary.targetEvidence.find((row) => row.targetId === target.targetId)?.outcome).toBe( "needs-review", diff --git a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts index f1460c7c..f123a870 100644 --- a/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts +++ b/tests/background/filed-returns-all-supported-full-fiscal-year.test.ts @@ -690,6 +690,73 @@ describe("all-supported full-fiscal-year worker", () => { expect(savedLedger().revision).toBeGreaterThan(blocked.revision); }); + it.each(["filed-gstr2b-not-generated", "artifact-filed-gstr2b-not-generated"])( + "stops a fresh all-supported run when staged output conflicts with %s", + async (refusal) => { + const runner = vi.fn(async (scope, childDeps) => { + if (scope.returnType !== "GSTR-2B") return notFiledStep(); + expect(childDeps.stageCapturedDownloads).toMatchObject({ + bundleKind: "all-supported-full-fiscal-year", + ledgerId: expect.any(String), + }); + // The selected-artifact runner accumulates the first format's staging evidence + // before returning the subsequent format's refusal to this active year loop. + return { + ok: true as const, + flowStep: { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "candidate-not-found" as const, + safeSignals: [ + "filed-return-artifact-downloaded:PDF", + "all-supported-full-fiscal-year-opfs-staged:PDF", + refusal, + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + safeMessage: "Synthetic later-format refusal.", + }, + }; + }); + + const response = await startAllSupportedFullFiscalYearDownloadFlow(request, deps, runner); + const ledger = savedLedger(); + const blockedIndex = ledger.targets.findIndex((target) => target.returnType === "GSTR-2B"); + expect(blockedIndex).toBeGreaterThanOrEqual(0); + const target = ledger.targets[blockedIndex]!; + expect(runner).toHaveBeenCalledTimes(blockedIndex + 1); + expect(target).toMatchObject({ + status: "blocked", + safeMessage: expect.stringContaining("retained a captured artifact"), + safeSignals: expect.arrayContaining([ + "all-supported-full-fiscal-year-opfs-staged:PDF", + "filed-return-artifact-downloaded:PDF", + refusal, + ]), + }); + expect( + ledger.targets.slice(blockedIndex + 1).every((item) => item.status === "pending"), + ).toBe(true); + expect(ledger.zipPhase).toBeUndefined(); + expect(isAllSupportedFullFiscalYearLedger(ledger)).toBe(true); + expect(response).toMatchObject({ + flowStep: { state: "blocked", safeMessage: target.safeMessage }, + }); + expect(zip.export).not.toHaveBeenCalled(); + expect(zip.discard).not.toHaveBeenCalled(); + + runner.mockClear(); + const reopened = await startAllSupportedFullFiscalYearDownloadFlow(request, deps, runner); + expect(reopened).toMatchObject({ + flowStep: { state: "blocked", safeMessage: target.safeMessage }, + }); + expect(runner).not.toHaveBeenCalled(); + expect(savedLedger()).toEqual(ledger); + expect(zip.export).not.toHaveBeenCalled(); + expect(zip.discard).not.toHaveBeenCalled(); + }, + ); + it("blocks a later bound GSTR-2B refusal after a staged artifact without exporting again", async () => { const firstRunner = vi.fn(async (scope) => scope.returnType === "GSTR-2B" 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 117c36fa..c0bef44f 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 @@ -98,7 +98,11 @@ describe("a full-year run whose period the portal never generated", () => { await expect(persistLedger(deps, ledger)).resolves.toBeUndefined(); }); - it("rejects a not-generated period that still retains staged artifact evidence", () => { + it.each([ + "full-fiscal-year-opfs-staged:PDF", + "all-supported-full-fiscal-year-opfs-staged:PDF", + "filed-return-artifact-downloaded:PDF", + ])("rejects a not-generated period retaining %s", (retainedSignal) => { const ledger = createFullFiscalYearLedger(scope, new Date("2026-09-10T00:00:00.000Z"), [ "April", ]); @@ -109,7 +113,7 @@ describe("a full-year run whose period the portal never generated", () => { "filed-gstr2b-not-generated", "gstr2b-summary-route-verified", "gstr2b-visible-period-verified", - "full-fiscal-year-opfs-staged:PDF", + retainedSignal, ], safeMessage: declinedArtifactSafeMessage("filed-gstr2b-not-generated"), }; diff --git a/tests/background/full-year-completion-start.test.ts b/tests/background/full-year-completion-start.test.ts index e17b064f..4dca5620 100644 --- a/tests/background/full-year-completion-start.test.ts +++ b/tests/background/full-year-completion-start.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FiledReturnsFlowRunnerDeps } from "../../src/background/filed-returns-flow-runner"; -import { startFullFiscalYearDownloadFlow } from "../../src/background/filed-returns-full-fiscal-year"; +import { + type SinglePeriodRunner, + startFullFiscalYearDownloadFlow, +} from "../../src/background/filed-returns-full-fiscal-year"; import { prepareFullFiscalYearTargetRetry, resolveFullFiscalYearTarget, } from "../../src/background/filed-returns-full-fiscal-year-recovery"; import { responseForExistingLedger } from "../../src/background/filed-returns-full-fiscal-year-run-state"; -import { summariseFullFiscalYearLedger } from "../../src/background/filed-returns-full-fiscal-year-summary"; +import { + fullFiscalYearTargetFlowStep, + summariseFullFiscalYearLedger, +} from "../../src/background/filed-returns-full-fiscal-year-summary"; import { canonicalDurableTargetStatus } from "../../src/connectors/gst/filed-returns-durable-status"; import { isFullFiscalYearLedger } from "../../src/background/filed-returns-full-fiscal-year-ledger"; import { @@ -277,6 +283,89 @@ describe("full-year Start preserves existing recovery", () => { expect(runSinglePeriod).toHaveBeenCalledTimes(1); }); + it("preserves an unrelated blocked reason despite retained staging", () => { + const step = { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "login-required" as const, + safeSignals: ["full-fiscal-year-opfs-staged:PDF", "portal-blocked-or-session-expired"], + safeMessage: "Synthetic session boundary requires the reader's attention.", + }; + expect(fullFiscalYearTargetFlowStep(step, "GSTR-2B")).toEqual(step); + expect( + canonicalDurableTargetStatus( + { ...RECOVERY_SCOPE, returnType: "GSTR-2B" }, + "blocked", + step.safeSignals, + ).safeMessage, + ).not.toContain("retained a captured artifact"); + }); + + it.each(["filed-gstr2b-not-generated", "artifact-filed-gstr2b-not-generated"])( + "stops a fresh ordinary year run when staged output conflicts with %s", + async (refusal) => { + const scope = { + ...RECOVERY_SCOPE, + returnType: "GSTR-2B" as const, + artifactType: "PDF_AND_EXCEL" as const, + }; + const runner = vi.fn(async (_scope, childDeps) => { + expect(childDeps.stageCapturedDownloads).toMatchObject({ + bundleKind: "full-fiscal-year", + ledgerId: expect.any(String), + }); + return { + ok: true as const, + flowStep: { + connectorId: "gst" as const, + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "candidate-not-found" as const, + safeSignals: [ + "filed-return-artifact-downloaded:PDF", + "full-fiscal-year-opfs-staged:PDF", + refusal, + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ], + safeMessage: "Synthetic later-format refusal.", + }, + }; + }); + const response = await startFullFiscalYearDownloadFlow(scope, deps, runner); + expect(runner).toHaveBeenCalledOnce(); + expect(isFullFiscalYearLedger(storage.local.ledger)).toBe(true); + if (!isFullFiscalYearLedger(storage.local.ledger)) throw new Error("Expected valid ledger."); + const ledger = structuredClone(storage.local.ledger); + const target = ledger.targets[0]!; + expect(target).toMatchObject({ + status: "blocked", + safeMessage: expect.stringContaining("retained a captured artifact"), + safeSignals: expect.arrayContaining([ + "full-fiscal-year-opfs-staged:PDF", + "filed-return-artifact-downloaded:PDF", + refusal, + ]), + }); + expect(ledger.targets.slice(1).every((item) => item.status === "pending")).toBe(true); + expect(ledger.zipPhase).toBeUndefined(); + expect(response).toMatchObject({ + flowStep: { state: "blocked", safeMessage: target.safeMessage }, + }); + expect(zipMocks.exportFullFiscalYearZip).not.toHaveBeenCalled(); + expect(zipMocks.discardFullFiscalYearFiledReturnsZip).not.toHaveBeenCalled(); + + runner.mockClear(); + const reopened = await startFullFiscalYearDownloadFlow(scope, deps, runner); + expect(reopened).toMatchObject({ + flowStep: { state: "blocked", safeMessage: target.safeMessage }, + }); + expect(runner).not.toHaveBeenCalled(); + expect(storage.local.ledger).toEqual(ledger); + expect(zipMocks.exportFullFiscalYearZip).not.toHaveBeenCalled(); + expect(zipMocks.discardFullFiscalYearFiledReturnsZip).not.toHaveBeenCalled(); + }, + ); + it("blocks an ordinary active retry after staged evidence and does not export", async () => { const scope = { ...RECOVERY_SCOPE, returnType: "GSTR-2B" as const }; const ledger = makeCompletedRecoveryLedger("blocked", { From d838918194de30e1c8de9fba79a71f44e2376bd8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:26:52 +0530 Subject: [PATCH 43/48] style(full-year): restore canonical ledger formatting --- .../filed-returns-all-supported-full-fiscal-year-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 881e91c4..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 @@ -399,7 +399,7 @@ export function markAllSupportedFullFiscalYearTargetTerminal( ...target, status: effectiveStatus, ...canonicalDurableTargetStatus(targetScope(target), effectiveStatus, inputSignals), - ...(diagnosticState ?? {}), + ...(diagnosticState ?? {}), ...(isResolvedFullFiscalYearTargetStatus(effectiveStatus) ? { completedAt: timestamp } : {}), From 84d292ec9525a6f40af4c77a62ae2cf0a07cb87a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 02:26:52 +0530 Subject: [PATCH 44/48] test(full-year): report unexpected export as runner regression --- tests/background/full-year-completion-start.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/background/full-year-completion-start.test.ts b/tests/background/full-year-completion-start.test.ts index 4dca5620..70fc424b 100644 --- a/tests/background/full-year-completion-start.test.ts +++ b/tests/background/full-year-completion-start.test.ts @@ -309,6 +309,13 @@ describe("full-year Start preserves existing recovery", () => { returnType: "GSTR-2B" as const, artifactType: "PDF_AND_EXCEL" as const, }; + zipMocks.exportFullFiscalYearZip.mockResolvedValue({ + connectorId: "gst", + scopeId: "gst-filed-returns-gstr2b-pdf-private-v0", + state: "blocked", + safeSignals: ["full-fiscal-year-zip-export-failed"], + safeMessage: "Synthetic unexpected ZIP export.", + }); const runner = vi.fn(async (_scope, childDeps) => { expect(childDeps.stageCapturedDownloads).toMatchObject({ bundleKind: "full-fiscal-year", From 7b50fd2f036fa85b3bed32d08c9d6f9cec8242e3 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 12:15:11 +0530 Subject: [PATCH 45/48] fix(bundle): preserve refusal binding proof --- ...led-returns-single-period-bundle-ledger.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/background/filed-returns-single-period-bundle-ledger.ts b/src/background/filed-returns-single-period-bundle-ledger.ts index 0f6170d5..99333c9f 100644 --- a/src/background/filed-returns-single-period-bundle-ledger.ts +++ b/src/background/filed-returns-single-period-bundle-ledger.ts @@ -403,7 +403,7 @@ export function markSinglePeriodBundleArtifactUnavailable( completedAt: now.toISOString(), ...(diagnostic ? { downloadDiagnostic: diagnostic } : {}), missingReason, - safeSignals: ["single-period-bundle-artifact-unavailable"], + safeSignals: unavailableSignals(flowStep, ledger.scope, artifactType), startedAt: artifact.startedAt!, status: "unavailable", updatedAt: now.toISOString(), @@ -436,7 +436,7 @@ export function markSinglePeriodBundlePeriodUnavailable( artifactType: artifact.artifactType, completedAt: timestamp, missingReason: missingReasons[index]!, - safeSignals: ["single-period-bundle-artifact-unavailable"], + safeSignals: unavailableSignals(flowStep, ledger.scope, artifact.artifactType), startedAt: artifact.startedAt ?? timestamp, status: "unavailable" as const, updatedAt: timestamp, @@ -655,7 +655,6 @@ function parseArtifact( return null; } const expectedSignals = statusSignals(artifact.status, expectedArtifactType); - if (!sameStrings(artifact.safeSignals, expectedSignals)) return null; if (artifact.startedAt !== undefined && !isCanonicalTimestamp(artifact.startedAt)) return null; if (artifact.completedAt !== undefined && !isCanonicalTimestamp(artifact.completedAt)) return null; @@ -679,6 +678,11 @@ function parseArtifact( const missingReason = isMissingReason(artifact.missingReason) ? normaliseArtifactMissingReason(artifact.missingReason, scope.returnType, expectedArtifactType) : null; + const expectedStoredSignals = [ + ...expectedSignals, + ...unavailableProofSignals(scope, expectedArtifactType, missingReason), + ]; + if (!sameStrings(artifact.safeSignals, expectedStoredSignals)) return null; if (artifact.status === "pending") { if ( @@ -708,7 +712,7 @@ function parseArtifact( ...(artifact.completedAt ? { completedAt: artifact.completedAt } : {}), ...(diagnostic ? { downloadDiagnostic: diagnostic } : {}), ...(missingReason ? { missingReason } : {}), - safeSignals: expectedSignals, + safeSignals: expectedStoredSignals, ...(artifact.startedAt ? { startedAt: artifact.startedAt } : {}), status: artifact.status, updatedAt: artifact.updatedAt, @@ -888,6 +892,36 @@ function statusSignals( return [`single-period-bundle-artifact-${status}`]; } +function unavailableProofSignals( + scope: FiledReturnsDownloadScope, + artifactType: FiledReturnsConcreteArtifactType, + missingReason: string | null, +): string[] { + if ( + scope.returnType === "GSTR-1" && + artifactType === "EXCEL" && + missingReason === declinedArtifactReason("filed-gstr1-excel-no-details-available") + ) + return ["filed-gstr1-detail-period-verified"]; + if ( + scope.returnType === "GSTR-2B" && + missingReason === declinedArtifactReason("filed-gstr2b-not-generated") + ) + return ["gstr2b-summary-route-verified", "gstr2b-visible-period-verified"]; + return []; +} + +function unavailableSignals( + flowStep: PortalFlowStepResult, + scope: FiledReturnsDownloadScope, + artifactType: FiledReturnsConcreteArtifactType, +): string[] { + const reason = missingArtifactReason(flowStep, scope.returnType, artifactType); + return [ + ...statusSignals("unavailable", artifactType), + ...unavailableProofSignals(scope, artifactType, reason), + ]; +} function isSupportedBundleScope(input: unknown): input is FiledReturnsDownloadScope { if (!input || typeof input !== "object") return false; const scope = input as Partial; From 9d258652b57654cad2cb62b1d115f118fc0c1fbe Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 12:19:04 +0530 Subject: [PATCH 46/48] feat(evidence): account for not-generated GSTR-2B periods --- docs/LIVE_EVIDENCE_PROTOCOL.md | 5 +- scripts/create-live-run-evidence-template.mjs | 6 ++- scripts/lib/live-run-evidence-types.ts | 4 +- scripts/lib/live-run-evidence.ts | 43 +++++++++------ ...eturns-single-period-bundle-ledger.test.ts | 27 +++++++++- tests/core/live-run-evidence.test.ts | 53 +++++++++++++++++++ .../create-live-run-evidence-template.test.ts | 32 +++++++++++ 7 files changed, 149 insertions(+), 21 deletions(-) diff --git a/docs/LIVE_EVIDENCE_PROTOCOL.md b/docs/LIVE_EVIDENCE_PROTOCOL.md index 396aad36..b4d684d3 100644 --- a/docs/LIVE_EVIDENCE_PROTOCOL.md +++ b/docs/LIVE_EVIDENCE_PROTOCOL.md @@ -80,7 +80,10 @@ For a one-month or full-year exploratory run, record: each use one JSON row; - scenario: `single-period` or `full-year`; - outcome counts: eligible targets, downloaded, not filed, manually observed, - blocked, failed, duplicates; + blocked, failed, duplicates; schema V2 additionally records GSTR-2B + not-generated targets. V2 passing totals reconcile downloaded, not filed, + not generated, and manually observed targets to eligible targets. V1 evidence + remains valid without a synthesized not-generated count; - human verification checks; - service-worker and browser-restart checks for full-year runs; - clear-local-data result; diff --git a/scripts/create-live-run-evidence-template.mjs b/scripts/create-live-run-evidence-template.mjs index 14bf9779..4546d195 100644 --- a/scripts/create-live-run-evidence-template.mjs +++ b/scripts/create-live-run-evidence-template.mjs @@ -82,6 +82,7 @@ try { for (const field of [ "downloaded", "not-filed", + "not-generated", "manually-observed", "blocked", "failed", @@ -131,7 +132,7 @@ try { const zipSha256 = options["zip-sha256"] ?? readChromeZipSha256(packageJson.version); const evidence = { - schemaVersion: 1, + schemaVersion: 2, evidenceId: options["evidence-id"] ?? `pack-live-run-${new Date(startedAt).toISOString().slice(0, 10)}-${String( @@ -259,6 +260,7 @@ function defaultCounts(outcome, eligibleTargets) { return { downloaded: eligibleTargets, notFiled: 0, + notGenerated: 0, manuallyObserved: 0, blocked: 0, failed: 0, @@ -268,6 +270,7 @@ function defaultCounts(outcome, eligibleTargets) { return { downloaded: 0, notFiled: 0, + notGenerated: 0, manuallyObserved: 0, blocked: outcome === "blocked" ? eligibleTargets : 0, failed: outcome === "failed" ? eligibleTargets : 0, @@ -368,6 +371,7 @@ function createDownloadEvidenceRows({ function toCamelCountKey(key) { if (key === "not-filed") return "notFiled"; + if (key === "not-generated") return "notGenerated"; if (key === "manually-observed") return "manuallyObserved"; return key; } diff --git a/scripts/lib/live-run-evidence-types.ts b/scripts/lib/live-run-evidence-types.ts index 8468756d..f43664ae 100644 --- a/scripts/lib/live-run-evidence-types.ts +++ b/scripts/lib/live-run-evidence-types.ts @@ -35,7 +35,7 @@ export type LiveRunEvidenceLimitation = | "browser-state-not-captured"; export interface LiveRunEvidence { - schemaVersion: 1; + schemaVersion: 1 | 2; evidenceId: string; sourceCommit: string; gitTag: string; @@ -93,6 +93,8 @@ export interface LiveRunEvidenceCounts { blocked: number; failed: number; duplicates: number; + /** Required by schema V2; absent in preserved V1 evidence. */ + notGenerated?: number; } export interface LiveRunEvidenceChecks { diff --git a/scripts/lib/live-run-evidence.ts b/scripts/lib/live-run-evidence.ts index 2595f7eb..80212d0e 100644 --- a/scripts/lib/live-run-evidence.ts +++ b/scripts/lib/live-run-evidence.ts @@ -85,7 +85,7 @@ const LIVE_RUN_EVIDENCE_KEYS = [ "mediaArtifacts", ]; const BROWSER_KEYS = ["name", "version"]; -const COUNT_KEYS = [ +const COUNT_KEYS_V1 = [ "eligibleTargets", "downloaded", "notFiled", @@ -94,6 +94,7 @@ const COUNT_KEYS = [ "failed", "duplicates", ]; +const COUNT_KEYS_V2 = [...COUNT_KEYS_V1, "notGenerated"]; const CHECK_KEYS = [ "humanVerifiedAccount", "humanVerifiedPeriods", @@ -192,7 +193,7 @@ export function validateLiveRunEvidence(input: unknown): LiveRunEvidenceValidati if (!isRecord(input)) return { ok: false, errors: ["evidence must be an object"] }; requireOnlyKeys(input, LIVE_RUN_EVIDENCE_KEYS, "evidence", errors); - requireExact(input.schemaVersion, 1, "schemaVersion", errors); + requireSchemaVersion(input.schemaVersion, errors); requirePattern(input.sourceCommit, HEX_40, "sourceCommit", errors); requirePattern(input.gitTag, GIT_TAG, "gitTag", errors); requirePattern(input.zipSha256, HEX_64, "zipSha256", errors); @@ -225,7 +226,7 @@ export function validateLiveRunEvidence(input: unknown): LiveRunEvidenceValidati if (input.outcome === "pass" && input.profile !== "clean-test-profile") { errors.push("pass evidence must use clean-test-profile"); } - validateCounts(input.counts, input.outcome, errors); + validateCounts(input.counts, input.outcome, input.returnType, input.schemaVersion, errors); validateChecks(input.checks, input.scenario, input.outcome, errors); validateDownloadEvidence(input.downloadEvidence, input, errors); validateLimitations(input.limitations, input.outcome, errors); @@ -569,18 +570,29 @@ function expectedConcreteArtifactTypes(evidence: Record): strin ); } -function validateCounts(input: unknown, outcome: unknown, errors: string[]): void { +function validateCounts( + input: unknown, + outcome: unknown, + returnType: unknown, + schemaVersion: unknown, + errors: string[], +): void { if (!isRecord(input)) { errors.push("counts must be an object"); return; } - requireOnlyKeys(input, COUNT_KEYS, "counts", errors); - for (const field of COUNT_KEYS) { + const countKeys = schemaVersion === 2 ? COUNT_KEYS_V2 : COUNT_KEYS_V1; + requireOnlyKeys(input, countKeys, "counts", errors); + for (const field of countKeys) { requireNonNegativeInteger(input[field], `counts.${field}`, errors); } - if (!hasOnlyNumberCounts(input)) return; - const reconciled = input.downloaded + input.notFiled + input.manuallyObserved; + if (!hasOnlyNumberCounts(input, schemaVersion)) return; + const notGenerated = schemaVersion === 2 ? input.notGenerated : 0; + const reconciled = input.downloaded + input.notFiled + notGenerated + input.manuallyObserved; const observed = reconciled + input.blocked + input.failed; + if (notGenerated > 0 && returnType !== "GSTR-2B") { + errors.push("counts.notGenerated can be nonzero only for GSTR-2B"); + } if (outcome === "pass" && reconciled === 0) { errors.push("counts must include at least one reconciled target"); } else if (observed === 0) { @@ -736,13 +748,8 @@ function assertNoSensitiveMarkers(input: unknown, errors: string[]): void { } } -function requireExact( - value: unknown, - expected: number | string | boolean, - field: string, - errors: string[], -): void { - if (value !== expected) errors.push(`${field} must be ${String(expected)}`); +function requireSchemaVersion(value: unknown, errors: string[]): void { + if (value !== 1 && value !== 2) errors.push("schemaVersion must be 1 or 2"); } function requirePattern( @@ -817,7 +824,8 @@ function stableJson(value: unknown): string { function hasOnlyNumberCounts( input: Record, -): input is Record { + schemaVersion: unknown, +): input is Record & { notGenerated?: number } { return ( typeof input.downloaded === "number" && typeof input.eligibleTargets === "number" && @@ -825,7 +833,8 @@ function hasOnlyNumberCounts( typeof input.manuallyObserved === "number" && typeof input.blocked === "number" && typeof input.failed === "number" && - typeof input.duplicates === "number" + typeof input.duplicates === "number" && + (schemaVersion !== 2 || typeof input.notGenerated === "number") ); } 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 63fb9501..4a95df90 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -806,7 +806,7 @@ function unavailableLedger( ...artifact, completedAt: PDF_STAGED_AT.toISOString(), missingReason, - safeSignals: ["single-period-bundle-artifact-unavailable"], + safeSignals: unavailableSafeSignals(scope, artifactType, missingReason), startedAt: PDF_RUNNING_AT.toISOString(), status: "unavailable" as const, updatedAt: PDF_STAGED_AT.toISOString(), @@ -830,6 +830,31 @@ function unavailableLedger( }; } +function unavailableSafeSignals( + scope: FiledReturnsDownloadScope, + artifactType: "PDF" | "EXCEL" | "JSON", + missingReason: string, +) { + if ( + scope.returnType === "GSTR-1" && + artifactType === "EXCEL" && + missingReason === "artifact-filed-gstr1-excel-no-details-available" + ) { + return ["single-period-bundle-artifact-unavailable", "filed-gstr1-detail-period-verified"]; + } + if ( + scope.returnType === "GSTR-2B" && + missingReason === "artifact-filed-gstr2b-not-generated" + ) { + return [ + "single-period-bundle-artifact-unavailable", + "gstr2b-summary-route-verified", + "gstr2b-visible-period-verified", + ]; + } + return ["single-period-bundle-artifact-unavailable"]; +} + async function persistBothArtifacts() { const initial = requiredLedger(); localValues[STORAGE_KEY] = initial; diff --git a/tests/core/live-run-evidence.test.ts b/tests/core/live-run-evidence.test.ts index e384a50a..45b8bc95 100644 --- a/tests/core/live-run-evidence.test.ts +++ b/tests/core/live-run-evidence.test.ts @@ -465,6 +465,59 @@ describe("live run evidence", () => { expect(result).toMatchObject({ ok: true }); }); + it("preserves V1 counts and accepts an all-not-generated GSTR-2B V2 pass", () => { + const v1WithNewCount = validateLiveRunEvidence({ + ...createValidEvidence(), + counts: { ...createValidEvidence().counts, notGenerated: 0 }, + }); + const v2 = validateLiveRunEvidence({ + ...createValidEvidence(), + schemaVersion: 2, + returnType: "GSTR-2B", + artifactType: "PDF", + counts: { + eligibleTargets: 12, + downloaded: 0, + notFiled: 0, + notGenerated: 12, + manuallyObserved: 0, + blocked: 0, + failed: 0, + duplicates: 0, + }, + downloadEvidence: [], + }); + + expect(v1WithNewCount.ok).toBe(false); + if (!v1WithNewCount.ok) expect(v1WithNewCount.errors).toContain("counts.notGenerated is not allowed"); + expect(v2).toMatchObject({ ok: true }); + }); + + it("requires V2 notGenerated, rejects it outside GSTR-2B, and keeps totals complete", () => { + const missing = validateLiveRunEvidence({ + ...createValidEvidence(), + schemaVersion: 2, + }); + const otherReturn = validateLiveRunEvidence({ + ...createValidEvidence(), + schemaVersion: 2, + counts: { ...createValidEvidence().counts, notGenerated: 1, notFiled: 9 }, + }); + const incomplete = validateLiveRunEvidence({ + ...createValidEvidence(), + schemaVersion: 2, + returnType: "GSTR-2B", + counts: { ...createValidEvidence().counts, notGenerated: 1, notFiled: 9 }, + }); + + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.errors).toContain("counts.notGenerated must be a non-negative integer"); + expect(otherReturn.ok).toBe(false); + if (!otherReturn.ok) expect(otherReturn.errors).toContain("counts.notGenerated can be nonzero only for GSTR-2B"); + expect(incomplete.ok).toBe(false); + if (!incomplete.ok) expect(incomplete.errors).toContain("pass evidence must reconcile every eligible target"); + }); + it("rejects duplicate downloaded target and action identities", () => { const first = createValidEvidence().downloadEvidence[0]; const result = validateLiveRunEvidence({ diff --git a/tests/scripts/create-live-run-evidence-template.test.ts b/tests/scripts/create-live-run-evidence-template.test.ts index 905bc228..e35efc5a 100644 --- a/tests/scripts/create-live-run-evidence-template.test.ts +++ b/tests/scripts/create-live-run-evidence-template.test.ts @@ -264,6 +264,38 @@ describe("live evidence template generator", () => { expect(evidence.downloadEvidence).toEqual([]); }); + it("emits V2 GSTR-2B not-generated counts without fabricated download rows", () => { + const evidence = runTemplate([ + "--return-type", + "GSTR-2B", + "--artifact-type", + "PDF", + "--financial-year", + "2025-26", + "--period", + "FULL_FISCAL_YEAR", + "--outcome", + "pass", + "--downloaded", + "0", + "--not-generated", + "12", + "--clean-test-profile", + "--human-verified-account", + "--human-verified-periods", + "--all-files-non-empty", + "--service-worker-restart-resume-checked", + "--browser-restart-resume-checked", + "--clear-local-data-checked", + "--browser-summary-captured", + ...stableArgs, + ]); + + expect(validateLiveRunEvidence(evidence)).toMatchObject({ ok: true }); + expect(evidence).toMatchObject({ schemaVersion: 2, counts: { notGenerated: 12 } }); + expect(evidence.downloadEvidence).toEqual([]); + }); + it("defaults GSTR-3B evidence to the capture-first runtime path", () => { const evidence = runTemplate([ "--return-type", From a6e0d50a63afc7b1029efa7d69bd0c1c929bf6fa Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 12:25:34 +0530 Subject: [PATCH 47/48] docs(evidence): identify GSTR-2B as auto-drafted --- docs/LIVE_EVIDENCE_PROTOCOL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/LIVE_EVIDENCE_PROTOCOL.md b/docs/LIVE_EVIDENCE_PROTOCOL.md index b4d684d3..e4355a96 100644 --- a/docs/LIVE_EVIDENCE_PROTOCOL.md +++ b/docs/LIVE_EVIDENCE_PROTOCOL.md @@ -80,7 +80,7 @@ For a one-month or full-year exploratory run, record: each use one JSON row; - scenario: `single-period` or `full-year`; - outcome counts: eligible targets, downloaded, not filed, manually observed, - blocked, failed, duplicates; schema V2 additionally records GSTR-2B + blocked, failed, duplicates; schema V2 additionally records auto-drafted GSTR-2B not-generated targets. V2 passing totals reconcile downloaded, not filed, not generated, and manually observed targets to eligible targets. V1 evidence remains valid without a synthesized not-generated count; From 7b69dadf29fa0ea0c5dcf058e0fcf8f04b1c13c8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 13 Sep 2026 12:31:00 +0530 Subject: [PATCH 48/48] style(tests): format refusal and evidence assertions --- ...filed-returns-single-period-bundle-ledger.test.ts | 5 +---- tests/core/live-run-evidence.test.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) 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 4a95df90..e9e26a20 100644 --- a/tests/background/filed-returns-single-period-bundle-ledger.test.ts +++ b/tests/background/filed-returns-single-period-bundle-ledger.test.ts @@ -842,10 +842,7 @@ function unavailableSafeSignals( ) { return ["single-period-bundle-artifact-unavailable", "filed-gstr1-detail-period-verified"]; } - if ( - scope.returnType === "GSTR-2B" && - missingReason === "artifact-filed-gstr2b-not-generated" - ) { + if (scope.returnType === "GSTR-2B" && missingReason === "artifact-filed-gstr2b-not-generated") { return [ "single-period-bundle-artifact-unavailable", "gstr2b-summary-route-verified", diff --git a/tests/core/live-run-evidence.test.ts b/tests/core/live-run-evidence.test.ts index 45b8bc95..55494bce 100644 --- a/tests/core/live-run-evidence.test.ts +++ b/tests/core/live-run-evidence.test.ts @@ -489,7 +489,8 @@ describe("live run evidence", () => { }); expect(v1WithNewCount.ok).toBe(false); - if (!v1WithNewCount.ok) expect(v1WithNewCount.errors).toContain("counts.notGenerated is not allowed"); + if (!v1WithNewCount.ok) + expect(v1WithNewCount.errors).toContain("counts.notGenerated is not allowed"); expect(v2).toMatchObject({ ok: true }); }); @@ -511,11 +512,14 @@ describe("live run evidence", () => { }); expect(missing.ok).toBe(false); - if (!missing.ok) expect(missing.errors).toContain("counts.notGenerated must be a non-negative integer"); + if (!missing.ok) + expect(missing.errors).toContain("counts.notGenerated must be a non-negative integer"); expect(otherReturn.ok).toBe(false); - if (!otherReturn.ok) expect(otherReturn.errors).toContain("counts.notGenerated can be nonzero only for GSTR-2B"); + if (!otherReturn.ok) + expect(otherReturn.errors).toContain("counts.notGenerated can be nonzero only for GSTR-2B"); expect(incomplete.ok).toBe(false); - if (!incomplete.ok) expect(incomplete.errors).toContain("pass evidence must reconcile every eligible target"); + if (!incomplete.ok) + expect(incomplete.errors).toContain("pass evidence must reconcile every eligible target"); }); it("rejects duplicate downloaded target and action identities", () => {