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
61 changes: 61 additions & 0 deletions docs/PORTAL_INTEGRATION_FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,64 @@ in page copy is not a control: decoy or non-actionable text carrying the same wo
suppress a real `View Summary` step and strand a target whose PDF was reachable.

**Not yet established.** Which periods render which shape, and whether a page can offer both.

## The portal declines to produce an artifact, in its own words (2026-09-10)

Two captures, live and authenticated, source-surfaces build. Neither is a failure to reach the
portal; both are the portal stating that the artifact does not exist for that period. One concerns
a filed return, GSTR-1; the other concerns the auto-drafted GSTR-2B statement, which the portal
drafts rather than the taxpayer filing it.

**Filed GSTR-1, e-invoice details workbook.** Clicking the Excel control raises a modal:

```
Information
No details available for download (This is relevant only if you have reported e-invoices).
[ OK ]
```

The portal returns this when the period has no e-invoice records to export, which its own advisory
states: the file "would be blank in case taxpayer is not e-invoicing". That is the portal's rule,
quoted from its own copy. Whether it applies to a given return is a fact about that return, and not
one this file records. Pack treats the modal as the artifact being unavailable and records it as
such.

**GSTR-2B summary.** The summary route renders an error panel:

```
Error!
GSTR-2B could not be generated by the System. ...
Attention: System will not generate GSTR 2B for the current return period in any one of the
following circumstances:
i. There are no records to generate GSTR 2B for the current return period
ii. GSTR 3B of last return period is not filed till GSTR 2B generation date of current return period
iii. You are a QRMP taxpayer and current return period is not a quarter ending month
```

**Why both mattered.** Retrying cannot change either within a run, yet Pack offered retry as the
remedy and stopped the fiscal-year run. The GSTR-1 case had a recogniser and a ledger path for
recording the absence; nothing sent the message between them, so it never ran. The GSTR-2B case had
no recogniser at all.

**Matched on the statement, not the causes.** The GSTR-2B panel's numbered conditions are advisory
text and can be reworded independently of the outcome; the outcome sentence is what Pack keys on.

**Not yet established.** Whether the GSTR-1 modal text varies for a taxpayer who does report
e-invoices but has none in a period, and whether GSTR-2B uses this same panel for a QRMP taxpayer
mid-quarter or a different one. One capture of each.

### The refusal panel outlives the period it was loaded for (2026-09-10)

Captured live during a full-fiscal-year run. The GSTR-2B summary route does not change per period
(`/gstr2b/auth/gstr2b/summary`), and after the portal renders the "could not be generated" panel it
keeps rendering that panel -- and the header block naming the period it belongs to -- until a new
Comment thread
lamemustafa marked this conversation as resolved.
dashboard search settles. A run that read the panel without also reading the header therefore got a
confident answer for every later period without navigating to any of them: a full-year run reported
twelve periods processed after a single navigation, while the header still named the first period
of that run.

The header is what makes the correct reading possible. Even with the download controls replaced by
the error panel, the portal still renders `GSTIN`, `Financial Year`, `Return Period` and
`Generation date` (the last one empty, which is itself the signal that nothing was drafted). So the
refusal is bindable to a period, and must be bound to one before it is recorded. An unreadable
header is "could not determine", not "matches" -- Pack navigates instead of answering.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary";
import type {
FiledReturnsAllSupportedFullFiscalYearFlowSummary,
FiledReturnsAllSupportedFullFiscalYearTargetEvidence,
Expand Down Expand Up @@ -275,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:")),
);
}
24 changes: 12 additions & 12 deletions src/background/filed-returns-all-supported-full-fiscal-year.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { filedReturnsTargetOutcome } from "./filed-returns-full-fiscal-year-summary";
import type {
FiledReturnsAllSupportedFullFiscalYearFlowSummary,
FiledReturnsAllSupportedFullFiscalYearRequest,
Expand Down Expand Up @@ -745,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function detectGstr2bNotGenerated(
//
// A target is a scope with an action id, so the same guard the observation path uses applies
// unchanged here. It fails closed: an unreadable header is "could not determine".
if (verifyVisibleGstr2bPeriod(documentRef, normalised, target)) return null;
if (verifyVisibleGstr2bPeriod(documentRef, normalised, target, true)) return null;

return {
connectorId: "gst",
Expand Down
94 changes: 85 additions & 9 deletions src/connectors/gst/gstr2b-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
hasGstr2bLoginEvidence,
isGstr2bAuthRoute,
isGstr2bSummaryPage,
isGstr2bSummaryRoute,
readDocumentText,
returnFromMismatchedGstr2bSummary,
verifyVisibleGstr2bPeriod,
Expand All @@ -32,6 +33,43 @@ import {
isReturnDashboardStillRendering,
selectGstr2bReturnDashboardFiltersAndSearch,
} from "./gstr2b-dashboard-filters";
import {
GSTR2B_NOT_GENERATED_SAFE_MESSAGE,
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
Comment thread
lamemustafa marked this conversation as resolved.
);
}

const FILED_RETURNS_ROUTE = /\/returns\/auth\/efiledReturns\/?$/i;

Expand Down Expand Up @@ -73,6 +111,45 @@ 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 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",
Comment thread
lamemustafa marked this conversation as resolved.
Comment thread
lamemustafa marked this conversation as resolved.
],
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 mismatchedReturnNavigation = returnFromMismatchedReturnPage(
documentRef,
scope,
Expand All @@ -81,15 +158,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,
Expand Down
28 changes: 20 additions & 8 deletions src/connectors/gst/gstr2b-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
}
Comment thread
lamemustafa marked this conversation as resolved.

export function isGstr2bSummaryPage(documentRef: Document, normalisedText: string): boolean {
const pathname = documentRef.defaultView?.location.pathname ?? "";
return (
Expand Down Expand Up @@ -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");
Expand All @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/entrypoints/panel/panel-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,12 @@ export function PanelSurface({ pack }: { pack: PackPanelController }) {
</>
)}
{allSupportedNeedsRecovery ? (
// Named for its own run. Both blocks can render at once, and unlabelled they read as
// one contradictory statement: a live run showed "could not start a full fiscal year
// run" -- the all-year plan's reason -- directly above a single-return run that had
// plainly started and processed twelve periods.
<p className="panel-recovery-reason">
Why Pack paused: {allSupportedSummary?.flowStep.safeMessage}
Why the all-returns year plan paused: {allSupportedSummary?.flowStep.safeMessage}
</p>
) : null}
{hasRecoveryActions(summary ?? null) ? (
Expand Down
36 changes: 36 additions & 0 deletions tests/background/all-supported-full-fiscal-year-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -610,3 +612,37 @@ 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");
});
});
Loading
Loading