From e762a8889dc4827d4560b6ae472bd2772de88080 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 13:10:55 +0530 Subject: [PATCH 1/3] refactor(gst): make an unbound refusal impossible to construct 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, across three pull requests -- and nothing stopped a fourth from not learning it. The check is no longer something an emitter remembers: `declinedArtifactStep` takes proof as its first argument, and the only values of that type come from a binder in this module. The brand is a type-only symbol exported nowhere, so no object literal written elsewhere satisfies it. A deliberate double assertion would still get through; this makes the check impossible to forget, which is how all four defects happened, not impossible to circumvent on purpose. Two duplicates go with it. The GSTR-2B safe message and its retry wording were written out in both emitters, identical in both, with nothing able to contradict them -- no test compares one emitter's copy with another's. They are now a `Record` over the declined signals, so a third refusal cannot be registered without deciding what a reader is told and what they are offered. The binding signals register by derivation. Writing one by hand is how seven periods a live run had correctly answered came back as needs-review: one unregistered token rejects the entire array it travels in, and a refusal is a terminal step whose signals are persisted. --- .../gst/filed-returns-declined-artifact.ts | 152 ++++++++++++++++++ .../gst/filed-returns-durable-signals.ts | 2 + 2 files changed, 154 insertions(+) create mode 100644 src/connectors/gst/filed-returns-declined-artifact.ts 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..0f0d8795 --- /dev/null +++ b/src/connectors/gst/filed-returns-declined-artifact.ts @@ -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 +> = { + "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[]; + }, +): 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, + }, + }; +} diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index eca34326..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"; @@ -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", From 5312c34031f38120cb0698e0b035b2d15a7699ff Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 13:10:56 +0530 Subject: [PATCH 2/3] refactor(gst): route all three refusal emitters through the binding No behaviour change intended. Each emitter already ran a guard and built its own result; each now presents the guard's proof and asks for the result to be built. The flow path keeps its extra step: a stale panel is not merely refused there, it is navigated away from, because the run still needs the period it asked for. `filed-returns-durable-status` reads the declined wording from the same table rather than importing a constant that said it a second time. --- .../gst/filed-returns-durable-status.ts | 4 +- .../filed-returns-post-click-blocked-state.ts | 77 +++++-------------- src/connectors/gst/gstr2b-flow.ts | 50 +++++------- 3 files changed, 42 insertions(+), 89 deletions(-) diff --git a/src/connectors/gst/filed-returns-durable-status.ts b/src/connectors/gst/filed-returns-durable-status.ts index bc7e8149..3009fea9 100644 --- a/src/connectors/gst/filed-returns-durable-status.ts +++ b/src/connectors/gst/filed-returns-durable-status.ts @@ -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, @@ -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.`, 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 17170921..237eddf9 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -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. // @@ -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, @@ -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 @@ -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, @@ -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, + }); } diff --git a/src/connectors/gst/gstr2b-flow.ts b/src/connectors/gst/gstr2b-flow.ts index 960f3be0..fdd611e0 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 { @@ -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. @@ -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( From 319d4ece0bbe87f000c3182a17a727f85ce7b71e Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 13:10:56 +0530 Subject: [PATCH 3/3] test(gst): pin that a refusal cannot be recorded without its proof The guarantee is a compile-time one, so the test is too: `tsc --noEmit` fails if either forgery below stops being an error. Removing the brand makes the `@ts-expect-error` unused, which is the mutation that proves it holds. --- ...scal-year-not-generated-round-trip.test.ts | 4 +- .../filed-returns-declined-artifact.test.ts | 106 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/connectors/filed-returns-declined-artifact.test.ts 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 77a73c37..269a50d3 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 @@ -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 = { @@ -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"), }; } 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..17e9b2d2 --- /dev/null +++ b/tests/connectors/filed-returns-declined-artifact.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + DECLINED_ARTIFACT_SIGNALS, + type DeclinedArtifactSignal, +} from "../../src/connectors/gst/filed-returns-acquisition-diagnostics"; +import { + REFUSAL_BINDING_SIGNALS, + 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("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, { + signal: "filed-gstr2b-not-generated", + returnType: "GSTR-2B", + 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(); + }); +});