Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/connectors/gst/filed-returns-declined-artifact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 {
readonly [boundToVisibleTarget]: true;
/** 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 --
* which is how seven periods a live run had correctly answered came back as "needs review".
*/
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 =
| { 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(safeSignals: readonly RefusalBindingSignal[]): RefusalBinding {
return { bound: { safeSignals } as unknown as VisibleTargetBinding };
}

/**
* 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 {
const mismatch = verifyFiledReturnsDownloadTarget(documentRef, target, []);
return mismatch ? { bound: null, mismatch } : bound(["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 {
const mismatch = verifyVisibleGstr2bPeriod(documentRef, normalisedText, scope, true);
return mismatch ? { bound: null, mismatch } : bound(["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<DeclinedArtifactSignal, { safeMessage: string; userActionMessage: string }>
> = {
"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(
binding: VisibleTargetBinding,
options: {
signal: DeclinedArtifactSignal;
returnType: FiledReturnsDownloadScope["returnType"];
safeSignals: readonly string[];
Comment thread
lamemustafa marked this conversation as resolved.
},
): PortalDownloadTriggerResult {
const copy = DECLINED_ARTIFACT_COPY[options.signal];
return {
connectorId: "gst",
scopeId: filedReturnScopeId(options.returnType),
state: "blocked",
safeSignals: Array.from(
new Set([...options.safeSignals, ...binding.safeSignals, options.signal]),
),
safeMessage: copy.safeMessage,
userAction: {
type: "RETRY_PORTAL_GENERATION",
message: copy.userActionMessage,
canResume: true,
},
};
}
2 changes: 2 additions & 0 deletions src/connectors/gst/filed-returns-durable-signals.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -154,6 +155,7 @@ const EXACT_DURABLE_SIGNALS = new Set([
// 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",
Expand Down
4 changes: 2 additions & 2 deletions src/connectors/gst/filed-returns-durable-status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GSTR2B_NOT_GENERATED_SAFE_MESSAGE } from "./filed-returns-post-click-blocked-state";
import { declinedArtifactSafeMessage } from "./filed-returns-declined-artifact";
import {
FILED_RETURNS_FILTER_DEADLINE_EXPIRED_MESSAGE,
filedReturnsFilterActionRequiredMessage,
Expand Down Expand Up @@ -652,7 +652,7 @@ function renderDurableMessage(key: DurableMessageKey, scope: FiledReturnsDownloa
"not-filed": "The GST Portal reported no filed return for the selected period.",
// The portal declined to produce the artifact, in its own words. Retrying cannot change that,
// so the copy must not send the user to Downloads looking for a file that was never created.
"not-generated": GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
"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.`,
Expand Down
77 changes: 21 additions & 56 deletions src/connectors/gst/filed-returns-post-click-blocked-state.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { PortalDownloadTriggerResult } from "../../core/contracts";
import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts";
import {
bindGstr1DetailRefusal,
bindGstr2bSummaryRefusal,
declinedArtifactStep,
} from "./filed-returns-declined-artifact";
import { normaliseText } from "./filed-returns-dom";
import { filedReturnScopeId } from "./filed-returns-return-descriptors";
import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target";
import { readDocumentText, verifyVisibleGstr2bPeriod } from "./gstr2b-summary";
import { readDocumentText } from "./gstr2b-summary";

// The portal declining to produce an artifact, in its own words.
//
Expand All @@ -17,10 +20,6 @@ import { readDocumentText, verifyVisibleGstr2bPeriod } from "./gstr2b-summary";
// records a download: every result here is `blocked`, and completion still requires correlated
// download evidence.

function withSignal(safeSignals: string[], signal: string): string[] {
return safeSignals.includes(signal) ? [...safeSignals] : [...safeSignals, signal];
}

export function detectPostClickBlockedState(
documentRef: Document,
target: FiledReturnsDownloadTarget,
Expand Down Expand Up @@ -55,27 +54,14 @@ function detectGstr1ExcelNoDetails(
// 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.
//
// This is the same guard that binds a download click, asked the same question: the visible page
// must be this return type, this period, this financial year. Recording a refusal resolves the
// target outright and no artifact follows to corroborate it, so it is held to the same bar. It
// fails closed -- an unreadable detail header is "could not determine", never "matches".
if (verifyFiledReturnsDownloadTarget(documentRef, target, [])) return null;
const binding = bindGstr1DetailRefusal(documentRef, target);
if (!binding.bound) return null;

return {
connectorId: "gst",
scopeId: filedReturnScopeId(target.returnType),
state: "blocked",
safeSignals: withSignal(safeSignals, "filed-gstr1-excel-no-details-available"),
safeMessage:
"The GST Portal reported that no e-invoice details are available for this filed GSTR-1 period, so Pack did not record an Excel download. Retry after e-invoice details are available, or run PDF-only for this period.",
userAction: {
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,
},
};
return declinedArtifactStep(binding.bound, {
signal: "filed-gstr1-excel-no-details-available",
returnType: target.returnType,
safeSignals,
});
}

// Captured live on 2026-09-10. The summary page renders an error panel naming the system's own
Expand All @@ -89,16 +75,6 @@ export function isGstr2bNotGeneratedText(pageText: string): boolean {
return /\bgstr[\s-]?2b\s+could\s+not\s+be\s+generated\b/i.test(pageText);
}

/**
* One wording, used by the step that observes the refusal and by the record it becomes.
*
* These were two strings saying the same thing differently -- the kind of duplicate nothing in
* this repo can contradict, because no test compares a transient message with the durable one
* that replaces it.
*/
export const GSTR2B_NOT_GENERATED_SAFE_MESSAGE =
"The GST Portal reported that it did not generate the auto-drafted GSTR-2B statement for this period, so there is nothing for Pack to download. Pack recorded the period as unavailable rather than retrying.";

function detectGstr2bNotGenerated(
documentRef: Document,
normalised: string,
Expand All @@ -110,24 +86,13 @@ function detectGstr2bNotGenerated(
// The refusal panel is not bound to the target by the fact that it is on screen. The summary
// route does not change per period and keeps rendering the panel -- and the header naming the
// period it belongs to -- until a new search settles, so a stale panel will answer for whichever
// target asks. Recording it resolves that target outright, with no artifact to corroborate it
// afterwards, which makes the visible header the whole of the evidence.
//
// A target is a scope with an action id, so the same guard the observation path uses applies
// unchanged here. It fails closed: an unreadable header is "could not determine".
if (verifyVisibleGstr2bPeriod(documentRef, normalised, target, true)) return null;
// target asks.
const binding = bindGstr2bSummaryRefusal(documentRef, normalised, target);
if (!binding.bound) return null;

return {
connectorId: "gst",
scopeId: filedReturnScopeId(target.returnType),
state: "blocked",
safeSignals: withSignal(safeSignals, "filed-gstr2b-not-generated"),
safeMessage: GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
userAction: {
type: "RETRY_PORTAL_GENERATION",
message:
"Check the GST Portal's stated reason for this period. Retry only once the portal generates a GSTR-2B for it.",
canResume: true,
},
};
return declinedArtifactStep(binding.bound, {
signal: "filed-gstr2b-not-generated",
returnType: target.returnType,
safeSignals,
});
}
50 changes: 19 additions & 31 deletions src/connectors/gst/gstr2b-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,10 +34,7 @@ import {
isReturnDashboardStillRendering,
selectGstr2bReturnDashboardFiltersAndSearch,
} from "./gstr2b-dashboard-filters";
import {
GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
isGstr2bNotGeneratedText,
} from "./filed-returns-post-click-blocked-state";
import { isGstr2bNotGeneratedText } from "./filed-returns-post-click-blocked-state";

/**
* `null` when the visible page is the requested period, otherwise the step that leaves it.
Expand Down Expand Up @@ -121,33 +119,23 @@ export async function runGstr2bDownloadStep(
// recorded eleven months the run never navigated to. The guard fails closed: an unreadable
// period is "could not determine", never "matches".
if (isGstr2bSummaryRoute(documentRef) && isGstr2bNotGeneratedText(normalised)) {
const leaving = leaveUnlessVisiblePeriodMatches(
documentRef,
normalised,
scope,
scopeId,
safeSignals,
true,
);
if (leaving) return leaving;
return {
connectorId: "gst",
scopeId,
state: "blocked",
safeSignals: [
...safeSignals,
"gstr2b-summary-route",
"gstr2b-visible-period-verified",
"filed-gstr2b-not-generated",
],
safeMessage: GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
userAction: {
type: "RETRY_PORTAL_GENERATION",
message:
"Check the GST Portal's stated reason for this period. Retry only once the portal generates a GSTR-2B for it.",
canResume: true,
},
};
const 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, {
signal: "filed-gstr2b-not-generated",
returnType: scope.returnType,
safeSignals: [...safeSignals, "gstr2b-summary-route"],
});
}

const mismatchedReturnNavigation = returnFromMismatchedReturnPage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
markFullFiscalYearTargetTerminal,
} from "../../src/background/filed-returns-full-fiscal-year-ledger";
import { exportFullFiscalYearZip } from "../../src/background/filed-returns-full-fiscal-year-zip";
import { GSTR2B_NOT_GENERATED_SAFE_MESSAGE } from "../../src/connectors/gst/filed-returns-post-click-blocked-state";
import { declinedArtifactSafeMessage } from "../../src/connectors/gst/filed-returns-declined-artifact";
import type { FiledReturnsDownloadScope } from "../../src/connectors/gst/filed-returns-contracts";

const deps = {
Expand All @@ -54,7 +54,7 @@ function notGeneratedStep() {
scopeId: "gst-gstr2b-private-v0",
state: "blocked" as const,
safeSignals: ["gstr2b-summary-route", "filed-gstr2b-not-generated"],
safeMessage: GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
safeMessage: declinedArtifactSafeMessage("filed-gstr2b-not-generated"),
};
}

Expand Down
Loading
Loading