Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ The following table shows all transactions supported by the FinTSClient interfac
| **Account Statements** | `getAccountStatements(accountNumber, from?, to?)` | Fetches account transactions/statements for a date range (MT940 or CAMT format) | HKKAZ, HKCAZ | ✓ | ✓ |
| **Portfolio** | `getPortfolio(accountNumber, currency?, priceQuality?, maxEntries?)` | Fetches securities portfolio information for depot accounts | HKWPD | ✓ | ✓ |
| **Credit Card Statements** | `getCreditCardStatements(accountNumber, from?)` | Fetches credit card statements for credit card accounts | DKKKU | ✓ | ✓ |
| **Electronic Statements** | `getElectronicStatements(accountNumber, options?)` | Fetches the statement document from the electronic mailbox, usually a PDF | HKEKA | ✓ | ✓ |
| **TAN Method Selection** | `selectTanMethod(tanMethodId)` | Selects a TAN method by ID from available methods | - | ❌ | ❌ |
| **TAN Media Selection** | `selectTanMedia(tanMediaName)` | Selects a specific TAN media device by name | - | ❌ | ❌ |

Expand All @@ -214,6 +215,7 @@ For each account-specific transaction, the client provides corresponding `can*`
| `canGetAccountStatements(accountNumber?)` | Checks if account statements fetching is supported (MT940/CAMT) |
| `canGetPortfolio(accountNumber?)` | Checks if portfolio information fetching is supported |
| `canGetCreditCardStatements(accountNumber?)` | Checks if credit card statements fetching is supported |
| `canGetElectronicStatements(accountNumber?)` | Checks if electronic account statements fetching is supported |

### Transaction Parameters

Expand Down
56 changes: 56 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import type {
CustomerOrderInteraction,
StatementResponse,
} from './interactions/customerInteraction.js';
import {
ElectronicStatementInteraction,
type ElectronicStatementOptions,
type ElectronicStatementResponse,
} from './interactions/electronicStatementInteraction.js';
import type { InitResponse } from './interactions/initDialogInteraction.js';
import {
PortfolioInteraction,
Expand All @@ -19,6 +24,7 @@ import { StatementInteractionCAMT } from './interactions/statementInteractionCAM
import { StatementInteractionMT940 } from './interactions/statementInteractionMT940.js';
import { DKKKU } from './segments/DKKKU.js';
import { HKCAZ } from './segments/HKCAZ.js';
import { HKEKA } from './segments/HKEKA.js';
import { HKIDN } from './segments/HKIDN.js';
import { HKKAZ } from './segments/HKKAZ.js';
import { HKSAL } from './segments/HKSAL.js';
Expand Down Expand Up @@ -284,6 +290,56 @@ export class FinTSClient {
)) as StatementResponse;
}

/**
* Checks if the bank supports fetching electronic account statements in general or for the given account number
* @param accountNumber when the account number is provided, checks if the account supports fetching of electronic statements
* @returns true if the bank (and account) supports fetching electronic account statements
*/
canGetElectronicStatements(accountNumber?: string): boolean {
return accountNumber
? this.config.isAccountTransactionSupported(accountNumber, HKEKA.Id)
: this.config.isTransactionSupported(HKEKA.Id);
}

/**
* Fetches an electronic account statement (Elektronischer Kontoauszug) for the given account number
*
* This returns the statement document the bank files in the customer's electronic mailbox,
* usually a PDF, not a list of transactions. The bank hands out one statement per call and
* announces a waiting successor in `nextOffset`; pass that value back in `options.offset` to
* fetch the next one. Banks that set `receiptRequired` in their HIEKAS parameters keep
* offering a statement until it has been acknowledged with its receipt.
*
* @param accountNumber - the account number to fetch the statement for, must be an account available in the config.bankingInformation.upd.accounts
* @param options - optional format, statement number and year, entry limit and offset
* @returns a response containing the statement documents and the offset of a waiting successor
*/
async getElectronicStatements(
accountNumber: string,
options?: ElectronicStatementOptions,
): Promise<ElectronicStatementResponse> {
return (await this.startCustomerOrderInteraction(
new ElectronicStatementInteraction(accountNumber, options),
)) as ElectronicStatementResponse;
}

/**
* Continues the electronic account statement fetching when a TAN is required
* @param tanReference The TAN reference provided in the first call's response
* @param tan The TAN entered by the user, can be omitted if a decoupled TAN method is used
* @returns a response containing the statement documents
*/
async getElectronicStatementsWithTan(
tanReference: string,
tan?: string,
): Promise<ElectronicStatementResponse> {
return (await this.continueCustomerInteractionWithTan(
[HKEKA.Id],
tanReference,
tan,
)) as ElectronicStatementResponse;
}

private async startCustomerOrderInteraction(
interaction: CustomerOrderInteraction,
): Promise<ClientResponse> {
Expand Down
22 changes: 21 additions & 1 deletion src/dataElements/Binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,27 @@ export class Binary extends DataElement {
return `@${value.length}@${value}`;
}

/**
* A binary value arrives as `@<length>@<data>`. The length is authoritative: it is the
* only thing that tells data apart from the separators and escape characters that a
* binary payload — a PDF, for instance — is full of. Returning everything after the
* second `@` instead would hand out whatever the bank appended between the end of the
* data and the next separator.
*/
decode(text: string) {
return text.slice(text.indexOf('@', 1) + 1);
if (text[0] !== '@') {
// Not length-prefixed — nothing to go by, take it as it is.
return text;
}

const lengthEnd = text.indexOf('@', 1);
if (lengthEnd < 0) {
return text;
}

const dataStart = lengthEnd + 1;
const length = Number.parseInt(text.slice(1, lengthEnd), 10);

return Number.isNaN(length) ? text.slice(dataStart) : text.slice(dataStart, dataStart + length);
}
}
51 changes: 51 additions & 0 deletions src/electronicStatement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* An electronic account statement (Elektronischer Kontoauszug) as handed out by the bank.
*
* Unlike {@link Statement} this is not a list of parsed transactions but the statement
* document itself — the same document the bank files in the customer's electronic mailbox,
* usually a PDF.
*/
export type ElectronicStatement = {
/** The format of {@link document}, as announced by the bank in the HIEKAS parameters */
format: string;

/** Start of the period the statement covers */
from?: Date;

/** End of the period the statement covers */
to?: Date;

/** The date the statement was created by the bank */
date?: Date;

/** The year the statement number refers to, statement numbers restart every year */
year?: number;

/** The sequential number of the statement within its year */
number?: number;

/** The statement document itself */
document: Uint8Array;

/** Information about the closing of the accounting period, when the bank provides it */
closingInfo?: string;

/** Information about the conditions of the account, when the bank provides it */
conditionsInfo?: string;

/** Advertising text, when the bank provides it */
advertisement?: string;

iban?: string;
bic?: string;

/** The account holder's name, joined from the up to three name lines the bank sends */
accountName?: string;

/**
* The receipt for this statement. When the bank requires acknowledgement
* (`receiptRequired` in the HIEKAS parameters), it only stops handing out a statement
* once it has been acknowledged with this receipt.
*/
receipt?: string;
};
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ export * from './bpd.js';
export * from './client.js';
export * from './config.js';
export * from './dialog.js';
export * from './electronicStatement.js';
export * from './httpClient.js';
export { AccountBalanceResponse } from './interactions/balanceInteraction.js';
export { ClientResponse, StatementResponse } from './interactions/customerInteraction.js';
export {
ElectronicStatementOptions,
ElectronicStatementResponse,
} from './interactions/electronicStatementInteraction.js';
export { PortfolioResponse } from './interactions/portfolioInteraction.js';
export * from './message.js';
export * from './mt535parser.js';
export * from './mt940parser.js';
export * from './segment.js';
export { StatementFormat } from './segments/HKEKA.js';
export * from './statement.js';
export * from './upd.js';
129 changes: 129 additions & 0 deletions src/interactions/electronicStatementInteraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { FinTSConfig } from '../config.js';
import type { ElectronicStatement } from '../electronicStatement.js';
import type { Message } from '../message.js';
import type { Segment } from '../segment.js';
import { HIEKA, type HIEKASegment } from '../segments/HIEKA.js';
import type { HIEKASParameter } from '../segments/HIEKAS.js';
import { HKEKA, type HKEKASegment, type StatementFormat } from '../segments/HKEKA.js';
import { type ClientResponse, CustomerOrderInteraction } from './customerInteraction.js';

export interface ElectronicStatementResponse extends ClientResponse {
statements: ElectronicStatement[];
/**
* The offset to pass to the next call when the bank announced further documents
* (answer code 3040), undefined when no more statements are waiting.
*/
nextOffset?: string;
}

export interface ElectronicStatementOptions {
/** The format to request, defaults to the first format the bank announces in HIEKAS */
format?: StatementFormat;
/** Fetch one specific statement, only allowed when the bank sets `indexAllowed` */
number?: number;
/** The year the statement number refers to */
year?: number;
maxEntries?: number;
/** The offset from a previous response's `nextOffset` */
offset?: string;
}

/**
* Turns the latin1 string the parser produced back into the bytes the bank sent.
*/
function toBytes(binary: string): Uint8Array {
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i) & 0xff;
}
return bytes;
}

const PDF_MAGIC = '%PDF';

/**
* Some banks base64-encode the document although the field is declared binary — a known
* quirk of HIEKP v1 that may apply here as well.
*
* This only unwraps when it can prove the result: the payload must consist of base64
* characters only AND decode to something that actually starts with a PDF header.
* Anything else is passed through untouched, so a document is never silently mangled.
*/
function unwrapBase64(bytes: Uint8Array): Uint8Array {
const text = new TextDecoder('latin1').decode(bytes);

if (text.startsWith(PDF_MAGIC) || !/^[A-Za-z0-9+/\s]+={0,2}\s*$/.test(text)) {
return bytes;
}

try {
const decoded = Buffer.from(text, 'base64');
return decoded.subarray(0, PDF_MAGIC.length).toString('latin1') === PDF_MAGIC
? new Uint8Array(decoded)
: bytes;
} catch {
return bytes;
}
}

export class ElectronicStatementInteraction extends CustomerOrderInteraction {
constructor(
public accountNumber: string,
public options: ElectronicStatementOptions = {},
) {
super(HKEKA.Id, HIEKA.Id);
}

createSegments(init: FinTSConfig): Segment[] {
const bankAccount = init.getBankAccount(this.accountNumber);
const version = init.getMaxSupportedTransactionVersion(HKEKA.Id);
if (!version) {
throw Error(`There is no supported version for business transaction '${HKEKA.Id}'`);
}

const params = init.getTransactionParameters<HIEKASParameter>(HKEKA.Id);
const format =
this.options.format ?? (params?.supportedFormats?.[0] as StatementFormat | undefined);

const hkeka: HKEKASegment = {
header: { segId: HKEKA.Id, segNr: 0, version: version },
account: bankAccount,
statementFormat: format,
statementNumber: this.options.number,
statementYear: this.options.year,
maxEntries: this.options.maxEntries,
offset: this.options.offset,
};

return [hkeka];
}

handleResponse(response: Message, clientResponse: ElectronicStatementResponse) {
const segments = response.findAllSegments<HIEKASegment>(HIEKA.Id);

clientResponse.statements = segments.map((hieka) => {
const names = [hieka.name, hieka.name2, hieka.name3].filter((name) => !!name);

return {
format: hieka.format,
from: hieka.timeRange?.from,
to: hieka.timeRange?.to,
date: hieka.date,
year: hieka.year,
number: hieka.number,
document: unwrapBase64(toBytes(hieka.booked ?? '')),
closingInfo: hieka.closingInfo,
conditionsInfo: hieka.conditionsInfo,
advertisement: hieka.advertisement,
iban: hieka.iban,
bic: hieka.bic,
accountName: names.length > 0 ? names.join(' ') : undefined,
receipt: hieka.receipt,
};
});

clientResponse.nextOffset = clientResponse.bankAnswers.find(
(answer) => answer.code === 3040,
)?.params?.[0];
}
}
68 changes: 68 additions & 0 deletions src/segments/HIEKA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { AlphaNumeric } from '../dataElements/AlphaNumeric.js';
import { Binary } from '../dataElements/Binary.js';
import { Dat } from '../dataElements/Dat.js';
import { Numeric } from '../dataElements/Numeric.js';
import { DataGroup } from '../dataGroups/DataGroup.js';
import type { Segment } from '../segment.js';
import { SegmentDefinition } from '../segmentDefinition.js';
import type { StatementFormat } from './HKEKA.js';

export type HIEKASegment = Segment & {
format: StatementFormat;
timeRange?: { from?: Date; to?: Date };
date?: Date;
year?: number;
number?: number;
/** The statement document itself — a PDF when format is '3' */
booked: string;
closingInfo?: string;
conditionsInfo?: string;
advertisement?: string;
iban?: string;
bic?: string;
name?: string;
name2?: string;
name3?: string;
/** Receipt to acknowledge the statement with, when the bank requires acknowledgement */
receipt?: string;
};

/**
* Electronic account statement response (Elektronischer Kontoauszug)
*
* The element order follows the FinTS 3.0 specification, and two details of it are easy
* to get wrong:
*
* - `booked` sits AFTER date/year/number, not before them. HIEKP v2 orders the same
* fields the other way round — the order is specific to each segment and cannot be
* carried over from one to the other.
* - Only version 5 carries date/year/number at all; up to version 4 `booked` follows
* the time range directly, and version 1 has no iban/bic/name either. Decoding an
* older response with the version 5 layout does not fail, it silently shifts every
* field by three positions and hands out the advertisement text as the document.
*/
export class HIEKA extends SegmentDefinition {
static Id = 'HIEKA';
static Version = 5;
constructor() {
super(HIEKA.Id);
}
version = HIEKA.Version;
elements = [
new AlphaNumeric('format', 1, 1, 1),
new DataGroup('timeRange', [new Dat('from', 0, 1), new Dat('to', 0, 1)], 1, 1),
new Dat('date', 0, 1, 5),
new Numeric('year', 0, 1, 4, 5),
new Numeric('number', 0, 1, 5, 5),
new Binary('booked', 1, 1),
new AlphaNumeric('closingInfo', 0, 1, 65536),
new AlphaNumeric('conditionsInfo', 0, 1, 65536),
new AlphaNumeric('advertisement', 0, 1, 65536),
new AlphaNumeric('iban', 0, 1, 34, 2),
new AlphaNumeric('bic', 0, 1, 11, 2),
new AlphaNumeric('name', 0, 1, 35, 2),
new AlphaNumeric('name2', 0, 1, 35, 2),
new AlphaNumeric('name3', 0, 1, 35, 2),
new Binary('receipt', 0, 1),
];
}
Loading
Loading