diff --git a/packages/fints/src/__tests__/test-dialog.ts b/packages/fints/src/__tests__/test-dialog.ts index ab6d83e..67235c7 100644 --- a/packages/fints/src/__tests__/test-dialog.ts +++ b/packages/fints/src/__tests__/test-dialog.ts @@ -2,6 +2,7 @@ import { Dialog, DialogConfig } from "../dialog"; import { Request } from "../request"; import { TanRequiredError } from "../errors/tan-required-error"; import { ResponseError } from "../errors/response-error"; +import { DecoupledTanState } from "../decoupled-tan"; describe("Dialog", () => { const baseConfig: DialogConfig = { blz: "1", name: "user", pin: "123", systemId: "0" } as any; @@ -53,6 +54,24 @@ describe("Dialog", () => { } }); + test("send throws decoupled TanRequiredError for 3955 + HITAN without 0030", async () => { + const hitan = { transactionReference: "ref", challengeText: "text", challengeMedia: Buffer.alloc(0) }; + const connection = { + send: jest.fn().mockResolvedValue({ + success: true, + returnValues: () => new Map([["3955", { message: "Sicherheitsfreigabe erfolgt über anderen Kanal." }]]), + findSegment: () => hitan, + dialogId: "4711", + }), + }; + const dialog = new Dialog(baseConfig, connection as any); + const error = (await dialog.send(new Request(baseConfig)).catch((err) => err)) as TanRequiredError; + expect(error).toBeInstanceOf(TanRequiredError); + expect(error.decoupledTanState).toBe(DecoupledTanState.INITIATED); + expect(error.context?.returnCode).toBe("3955"); + expect(dialog.dialogId).toBe("4711"); + }); + test("capabilities getter reflects fields set during sync", () => { const dialog = new Dialog(baseConfig, {} as any); diff --git a/packages/fints/src/dialog.ts b/packages/fints/src/dialog.ts index b05a34d..f3f6b46 100644 --- a/packages/fints/src/dialog.ts +++ b/packages/fints/src/dialog.ts @@ -260,9 +260,19 @@ export class Dialog extends DialogConfig { if (!response.success) { throw new ResponseError(response); } - if (response.returnValues().has("0030")) { - const hitan = response.findSegment(HITAN); - const returnValue = response.returnValues().get("0030"); + const returnValues = response.returnValues(); + const hasTanRequiredCode = returnValues.has("0030"); + let hitan: HITAN | undefined; + let tanRequiredCode: "0030" | "3955" | undefined; + if (hasTanRequiredCode) { + tanRequiredCode = "0030"; + } else if (returnValues.has("3955")) { + hitan = response.findSegment(HITAN); + tanRequiredCode = hitan ? "3955" : undefined; + } + if (tanRequiredCode) { + hitan = hitan ?? response.findSegment(HITAN); + const returnValue = returnValues.get(tanRequiredCode); // Determine which segment triggered the TAN requirement const triggeringSegment = request.segments.length > 0 ? request.segments[0].type : undefined; @@ -270,13 +280,18 @@ export class Dialog extends DialogConfig { // Check for decoupled TAN indicators per FinTS 3.0 PINTAN specification: // - "3956": Indicates strong customer authentication (SCA) is pending on trusted device // - "3076": PSD2-mandated strong customer authentication required - // When either code is present alongside "0030", it signals decoupled TAN flow - // where the user must approve the transaction in a separate app (e.g., mobile banking) - const returnValues = response.returnValues(); - const isDecoupled = returnValues.has("3956") || returnValues.has("3076"); + // - "3955": Security approval takes place in another channel + // These codes indicate a decoupled TAN flow where the user must approve the + // transaction in a separate app or device. "3955" can either accompany "0030" + // or be the primary TAN-required return code itself when returned with HITAN. + const isDecoupled = returnValues.has("3956") || returnValues.has("3076") || returnValues.has("3955"); + const fallbackMessage = + tanRequiredCode === "3955" + ? "TAN required: Security approval via alternate channel (3955)" + : "TAN required"; const error = new TanRequiredError( - returnValue.message, + returnValue?.message ?? fallbackMessage, hitan.transactionReference, hitan.challengeText, hitan.challengeMedia, @@ -284,7 +299,7 @@ export class Dialog extends DialogConfig { TanProcessStep.CHALLENGE_RESPONSE_NEEDED, triggeringSegment, { - returnCode: "0030", + returnCode: tanRequiredCode, requestSegments: request.segments.map((s) => s.type), }, );