From 33261387ea153bcff873783213d3672791304388 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sat, 6 Dec 2025 14:23:25 +0100 Subject: [PATCH 1/6] added AI instructions and prompts --- .github/copilot-instructions.md | 90 +++++++++++++++++++ .../plan-camtStatementRetrieval.prompt.md | 83 +++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/prompts/plan-camtStatementRetrieval.prompt.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c6b479c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,90 @@ +# FinTS Banking Protocol Library - AI Coding Guidelines + +This TypeScript library implements the German FinTS 3.0 banking protocol for secure online banking communication with PIN/TAN authentication. + +## Architecture Overview + +### Core Components + +- **`FinTSClient`**: Main API - handles account balances, statements, portfolios, and credit card data +- **`FinTSConfig`**: Configuration factory with two modes: `forFirstTimeUse()` and `fromBankingInformation()` +- **Segments**: FinTS protocol message units (see `src/segments/`) - each has request/response pairs (e.g., `HKSAL`/`HISAL`) +- **Interactions**: High-level transaction orchestrators (see `src/interactions/`) that combine multiple segments +- **Dialog**: Session management for multi-step TAN authentication flows + +### Type System Pattern + +The library implements a sophisticated encoding/decoding system: + +- **DataElements** (`src/dataElements/`): Primitive types (Amount, Digits, Text, Binary, etc.) +- **DataGroups** (`src/dataGroups/`): Composite types (Account, Balance, Money, etc.) +- **Segments**: Protocol message definitions using DataElement/DataGroup compositions + +### Critical Workflow: TAN Authentication + +All transactions may require two-step TAN process: + +```typescript +let response = await client.getAccountStatements(account); +if (response.requiresTan) { + response = await client.getAccountStatementsWithTan(response.tanReference, userTAN); +} +``` + +## Development Patterns + +### Segment Registration + +All segments must be registered in `src/segments/registry.ts` - the `registerSegments()` call in `index.ts` is critical for protocol functionality. + +### Testing Approach + +- Use Vitest with mock patterns for `Dialog.prototype` methods +- Mock external HTTP communication, not internal protocol logic +- Test files follow `*.test.ts` naming in `src/tests/` +- Run tests: `pnpm test` + +### Error Handling + +Bank communication errors come via `response.bankAnswers` array with numeric codes. Success/failure is indicated by `response.success` boolean, not exceptions. + +### State Management + +- `bankingInformation` contains session state (BPD/UPD) and should be persisted between sessions +- Check `bankingInformationUpdated` flag in responses and re-persist when true +- `systemId` assignment is permanent per bank relationship + +## Key Integration Points + +### Bank Communication Flow + +1. **Initialization**: Create config → Initialize dialog → Send HKIDN/HKVVB/HKSYN segments +2. **TAN Method Selection**: Required before most transactions - available methods in BPD +3. **Transaction Execution**: Send business segment → Handle TAN challenge → Complete with TAN +4. **Session Cleanup**: Always call `dialog.end()` to properly close bank sessions + +### Protocol Encoding Rules + +- Messages use specific separators: `'` (segments), `+` (elements), `:` (sub-elements) +- Binary data uses `@length@data` format +- Escape character is `?` for literal separators +- See `parser.ts` and `encoder.ts`/`decoder.ts` for implementation details + +### Transaction Support Matrix + +Check capability with `can*()` methods (e.g., `canGetAccountBalance()`). Not all banks support all transactions. + +## Development Commands + +- **Build**: TypeScript compilation to ES2022 +- **Test**: `pnpm test` (Vitest) +- **Dependencies**: Zero runtime dependencies - self-contained protocol implementation + +## Important Constraints + +- **FinTS 3.0 only** - older protocol versions not supported +- **PIN/TAN authentication only** - no certificate-based auth +- **No payment transactions** - read-only operations (balances, statements, portfolios) +- **German banking focus** - country code defaults to 280 + +When extending functionality, follow the established segment definition pattern and ensure proper registration in the registry. diff --git a/.github/prompts/plan-camtStatementRetrieval.prompt.md b/.github/prompts/plan-camtStatementRetrieval.prompt.md new file mode 100644 index 0000000..ac61ab8 --- /dev/null +++ b/.github/prompts/plan-camtStatementRetrieval.prompt.md @@ -0,0 +1,83 @@ +# Plan: Implement CAMT based statement retrieval with intelligent fallback + +Enhance existing statement retrieval methods to automatically prefer CAMT format when supported by the bank, falling back to MT940 (HKKAZ) when CAMT is not available or when CAMT parsing fails. This maintains the current API while providing better data quality when possible. + +## Steps + +1. **Create CAMT segment definitions** - Implement [`HKCAZ`](src/segments/HKCAZ.ts), [`HICAZ`](src/segments/HICAZ.ts), and [`HICAZS`](src/segments/HICAZS.ts) following the same patterns as [`HKKAZ`](src/segments/HKKAZ.ts)/[`HIKAZ`](src/segments/HIKAZ.ts)/[`HIKAZS`](src/segments/HIKAZS.ts) + +2. **Build CAMT XML parser** - Create [`camtParser.ts`](src/camtParser.ts) using built-in `DOMParser` that implements the same `parse(): Statement[]` interface as [`Mt940Parser`](src/mt940parser.ts), mapping CAMT.053 XML to existing `Statement`, `Transaction`, and `Balance` interfaces + +3. **Enhance StatementInteraction with format selection** - Modify [`statementInteraction.ts`](src/interactions/statementInteraction.ts) to check CAMT support using `config.isAccountTransactionSupported(accountNumber, HKCAZ.Id)`, preferring CAMT when available and falling back to MT940 when not supported or parsing fails + +4. **Update capability checking** - Modify [`canGetAccountStatements()`](src/client.ts) method to return true if either CAMT or MT940 is supported using `config.isTransactionSupported()` and `config.isAccountTransactionSupported()`, maintaining backward compatibility + +5. **Register segments and exports** - Add CAMT segments to [`registry.ts`](src/segments/registry.ts) and export new CAMT parser from [`index.ts`](src/index.ts) for consistency + +6. **Add comprehensive tests** - Create test files for segments, parser, and enhanced interaction logic with format fallback scenarios including XML parsing error handling + +## Further Considerations + +The CAMT parser will map XML elements to existing fields (e.g., CAMT's structured creditor/debtor information to `remoteName`/`remoteAccountNumber`, ISO references to `e2eReference`/`mandateReference`) ensuring users get richer data when available while maintaining identical API surface. + +## Implementation Details + +### CAMT Segment Structure + +- **HKCAZ**: Request segment similar to HKKAZ but for CAMT format + - Same elements: account, date range, allAccounts, maxEntries, continuationMark + - Different segment ID to indicate CAMT format preference +- **HICAZ**: Response segment containing CAMT.053 XML data + - Binary field with CAMT XML content instead of MT940 text +- **HICAZS**: Business transaction parameters for HKCAZ + - Similar to HIKAZS with CAMT-specific capabilities + +### Format Selection Logic + +```typescript +// In StatementInteraction.createSegments() +const supportsCamt = config.isAccountTransactionSupported(accountNumber, HKCAZ.Id); +const supportsMt940 = config.isAccountTransactionSupported(accountNumber, HKKAZ.Id); + +if (supportsCamt) { + // Use HKCAZ segment +} else if (supportsMt940) { + // Use HKKAZ segment +} else { + // Throw error - no statement format supported +} +``` + +### Error Handling Strategy + +```typescript +// In StatementInteraction.handleResponse() +if (hicaz) { + try { + const parser = new CamtParser(hicaz.camtData); + clientResponse.statements = parser.parse(); + } catch (error) { + // If CAMT parsing fails and MT940 is supported, fallback + if (config.isAccountTransactionSupported(accountNumber, HKKAZ.Id)) { + // Retry with MT940 format + } else { + throw error; + } + } +} +``` + +### CAMT to Statement Mapping + +- **Statement level**: Map CAMT account statement to `Statement` +- **Transaction level**: Map CAMT transaction entries to `Transaction[]` +- **Balance level**: Map CAMT balance information to `Balance` +- **Party information**: Map structured CAMT party data to `remoteName`/`remoteAccountNumber` +- **Reference handling**: Map CAMT references to existing reference fields + +### Backward Compatibility + +- Existing `canGetAccountStatements()` returns true if either format is supported +- `StatementResponse.statements` remains `Statement[]` type +- No new client methods - transparent upgrade +- All existing MT940 functionality preserved as fallback From 6b277f864d4db50cf19f251a1f1f8cd8ff058125 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 7 Dec 2025 13:03:12 +0100 Subject: [PATCH 2/6] Implement CAMT parsing and segment handling - Added CamtParser class to parse CAMT.052 XML data into Statement objects. - Introduced HKCAZ and HICAZ segments for CAMT format statement retrieval. - Implemented StatementInteractionCAMT to handle CAMT responses and create segments. - Developed HICAZS segment for business transaction parameters related to CAMT. - Created tests for HICAZS, HKCAZ, and CamtParser to ensure correct encoding/decoding and parsing functionality. --- .github/copilot-instructions.md | 27 + src/bankAccount.ts | 59 +- src/camtParser.ts | 274 +++++++ src/client.ts | 46 +- src/config.ts | 3 +- src/httpClient.ts | 90 ++- src/index.ts | 3 +- src/interactions/customerInteraction.ts | 5 + src/interactions/statementInteraction.ts | 52 -- src/interactions/statementInteractionCAMT.ts | 56 ++ src/interactions/statementInteractionMT940.ts | 48 ++ src/mt940parser.ts | 689 +++++++++--------- src/segments/HICAZ.ts | 31 + src/segments/HICAZS.ts | 30 + src/segments/HKCAZ.ts | 39 + src/segments/registry.ts | 88 +-- src/statement.ts | 40 + src/tests/HICAZS.test.ts | 24 + src/tests/HKCAZ.test.ts | 59 ++ src/tests/camtParser.test.ts | 271 +++++++ 20 files changed, 1399 insertions(+), 535 deletions(-) create mode 100644 src/camtParser.ts delete mode 100644 src/interactions/statementInteraction.ts create mode 100644 src/interactions/statementInteractionCAMT.ts create mode 100644 src/interactions/statementInteractionMT940.ts create mode 100644 src/segments/HICAZ.ts create mode 100644 src/segments/HICAZS.ts create mode 100644 src/segments/HKCAZ.ts create mode 100644 src/statement.ts create mode 100644 src/tests/HICAZS.test.ts create mode 100644 src/tests/HKCAZ.test.ts create mode 100644 src/tests/camtParser.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c6b479c..3bcca17 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,6 +37,33 @@ if (response.requiresTan) { All segments must be registered in `src/segments/registry.ts` - the `registerSegments()` call in `index.ts` is critical for protocol functionality. +### DataElement maxCount Rules + +**Critical encoding constraint**: DataElements with `maxCount > 1` can only be the **last element** in a DataGroup or segment. This is a FinTS protocol requirement for proper parsing. + +**Correct patterns**: + +```typescript +// ✅ DataGroup with maxCount=1, internal element has maxCount>1 and is last +new DataGroup('acceptedFormats', [new Text('format', 1, 99)], 1, 1); + +// ✅ Direct element with maxCount>1 as last element in segment +elements = [ + new Text('someField', 1, 1), + new Binary('transactions', 0, 10000), // Last element can have maxCount>1 +]; +``` + +**Incorrect patterns**: + +```typescript +// ❌ DataElement with maxCount>1 not as last element +elements = [ + new Text('formats', 1, 99), // maxCount>1 but not last! + new YesNo('someFlag', 1, 1), // This breaks parsing +]; +``` + ### Testing Approach - Use Vitest with mock patterns for `Dialog.prototype` methods diff --git a/src/bankAccount.ts b/src/bankAccount.ts index bfb6458..d1ab01b 100644 --- a/src/bankAccount.ts +++ b/src/bankAccount.ts @@ -2,39 +2,40 @@ import { Account } from './dataGroups/Account.js'; import { AccountLimit, AllowedTransactions } from './segments/HIUPD.js'; export enum AccountType { - CheckingAccount = 'CheckingAccount', - SavingsAccount = 'SavingsAccount', - FixedDepositAccount = 'FixedDepositAccount', - SecuritiesAccount = 'SecuritiesAccount', - LoanMortgageAccount = 'LoanMortgageAccount', - CreditCardAccount = 'CreditCardAccount', - InvestmentCompanyFund = 'InvestmentCompanyFund', - HomeSavingsContract = 'HomeSavingsContract', - InsurancePolicy = 'InsurancePolicy', - Miscellaneous = 'Miscellaneous', + CheckingAccount = 'CheckingAccount', + SavingsAccount = 'SavingsAccount', + FixedDepositAccount = 'FixedDepositAccount', + SecuritiesAccount = 'SecuritiesAccount', + LoanMortgageAccount = 'LoanMortgageAccount', + CreditCardAccount = 'CreditCardAccount', + InvestmentCompanyFund = 'InvestmentCompanyFund', + HomeSavingsContract = 'HomeSavingsContract', + InsurancePolicy = 'InsurancePolicy', + Miscellaneous = 'Miscellaneous', } export type BankAccount = Account & { - iban?: string; - customerId: string; - accountType: AccountType; - currency: string; - holder1: string; - holder2?: string; - product?: string; - limit?: AccountLimit; - allowedTransactions?: AllowedTransactions[]; + iban?: string; + bic?: string; + customerId: string; + accountType: AccountType; + currency: string; + holder1: string; + holder2?: string; + product?: string; + limit?: AccountLimit; + allowedTransactions?: AllowedTransactions[]; }; export function finTsAccountTypeToEnum(accountType: number): AccountType { - if (accountType >= 1 && accountType <= 9) return AccountType.CheckingAccount; - if (accountType >= 10 && accountType <= 19) return AccountType.SavingsAccount; - if (accountType >= 20 && accountType <= 29) return AccountType.FixedDepositAccount; - if (accountType >= 30 && accountType <= 39) return AccountType.SecuritiesAccount; - if (accountType >= 40 && accountType <= 49) return AccountType.LoanMortgageAccount; - if (accountType >= 50 && accountType <= 59) return AccountType.CreditCardAccount; - if (accountType >= 60 && accountType <= 69) return AccountType.InvestmentCompanyFund; - if (accountType >= 70 && accountType <= 79) return AccountType.HomeSavingsContract; - if (accountType >= 80 && accountType <= 89) return AccountType.InsurancePolicy; - return AccountType.Miscellaneous; + if (accountType >= 1 && accountType <= 9) return AccountType.CheckingAccount; + if (accountType >= 10 && accountType <= 19) return AccountType.SavingsAccount; + if (accountType >= 20 && accountType <= 29) return AccountType.FixedDepositAccount; + if (accountType >= 30 && accountType <= 39) return AccountType.SecuritiesAccount; + if (accountType >= 40 && accountType <= 49) return AccountType.LoanMortgageAccount; + if (accountType >= 50 && accountType <= 59) return AccountType.CreditCardAccount; + if (accountType >= 60 && accountType <= 69) return AccountType.InvestmentCompanyFund; + if (accountType >= 70 && accountType <= 79) return AccountType.HomeSavingsContract; + if (accountType >= 80 && accountType <= 89) return AccountType.InsurancePolicy; + return AccountType.Miscellaneous; } diff --git a/src/camtParser.ts b/src/camtParser.ts new file mode 100644 index 0000000..e34fbc2 --- /dev/null +++ b/src/camtParser.ts @@ -0,0 +1,274 @@ +import { Statement, Transaction, Balance } from './statement.js'; + +export class CamtParser { + private xmlData: string; + + constructor(xmlData: string) { + this.xmlData = xmlData; + } + + parse(): Statement[] { + try { + const statements: Statement[] = []; + + // Parse multiple reports using regex (CAMT.053 can contain multiple reports) + const reportMatches = this.xmlData.match(/[\s\S]*?<\/Rpt>/g); + if (!reportMatches) { + return statements; + } + + for (const reportXml of reportMatches) { + const statement = this.parseReport(reportXml); + if (statement) { + statements.push(statement); + } + } + + return statements; + } catch (error) { + throw new Error(`Failed to parse CAMT data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + private parseReport(reportXml: string): Statement | null { + // Extract account information + const account = this.extractTagValue(reportXml, 'IBAN'); + + // Extract statement number/ID + const number = this.extractTagValue(reportXml, 'Id'); + + // Extract transaction reference + const transactionReference = this.extractTagValue(reportXml, 'ElctrncSeqNb'); + + // Parse balances + const balances = this.parseBalances(reportXml); + if (!balances.openingBalance || !balances.closingBalance) { + return null; // Need at least opening and closing balance + } + + // Parse transactions + const transactions = this.parseTransactions(reportXml); + + return { + account, + number, + transactionReference, + openingBalance: balances.openingBalance, + closingBalance: balances.closingBalance, + availableBalance: balances.availableBalance, + transactions, + }; + } + + private parseBalances(reportXml: string): { + openingBalance?: Balance; + closingBalance?: Balance; + availableBalance?: Balance; + } { + let openingBalance: Balance | undefined; + let closingBalance: Balance | undefined; + let availableBalance: Balance | undefined; + + // Extract all balance elements + const balanceMatches = reportXml.match(//g); + if (!balanceMatches) { + return { openingBalance, closingBalance, availableBalance }; + } + + for (const balanceXml of balanceMatches) { + const typeCode = this.extractTagValue(balanceXml, 'Cd'); + + // Extract amount and currency + const amtMatch = balanceXml.match(/([^<]+)<\/Amt>/); + const currency = amtMatch ? amtMatch[1] : 'EUR'; + const value = amtMatch ? parseFloat(amtMatch[2]) : 0; + + const creditDebitInd = this.extractTagValue(balanceXml, 'CdtDbtInd'); + const finalValue = creditDebitInd === 'DBIT' ? -value : value; + + const dateStr = this.extractTagValue(balanceXml, 'Dt'); + const date = dateStr ? this.parseDate(dateStr) : new Date(); + + const balance: Balance = { + date, + currency, + value: finalValue, + }; + + switch (typeCode) { + case 'PRCD': // Previous closing date + openingBalance = balance; + break; + case 'CLBD': // Closing booked + closingBalance = balance; + break; + case 'ITBD': // Interim booked + case 'FWAV': // Forward available + availableBalance = balance; + break; + } + } + + return { openingBalance, closingBalance, availableBalance }; + } + + private parseTransactions(reportXml: string): Transaction[] { + const transactions: Transaction[] = []; + const entryMatches = reportXml.match(//g); + + if (!entryMatches) { + return transactions; + } + + for (const entryXml of entryMatches) { + const transaction = this.parseTransaction(entryXml); + if (transaction) { + transactions.push(transaction); + } + } + + return transactions; + } + + private parseTransaction(entryXml: string): Transaction | null { + try { + // Extract amount and credit/debit indicator + const amtMatch = entryXml.match(/]*>([^<]+)<\/Amt>/); + const amountValue = amtMatch ? parseFloat(amtMatch[1]) : 0; + const creditDebitInd = this.extractTagValue(entryXml, 'CdtDbtInd'); + const isDebit = creditDebitInd === 'DBIT'; + const amount = isDebit ? -amountValue : amountValue; + + // Extract dates + const bookingDate = this.extractNestedTagValue(entryXml, 'BookgDt', 'Dt'); + const valueDate = this.extractNestedTagValue(entryXml, 'ValDt', 'Dt'); + + const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date(); + const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate; + + // Extract references + const accountServicerRef = this.extractTagValue(entryXml, 'AcctSvcrRef') || ''; + const endToEndId = this.extractTagValue(entryXml, 'EndToEndId') || ''; + const mandateId = this.extractTagValue(entryXml, 'MndtId') || ''; + + // Extract transaction details + const additionalEntryInfo = this.extractTagValue(entryXml, 'AddtlNtryInf') || ''; + const remittanceInfo = this.extractTagValue(entryXml, 'Ustrd') || ''; + + // Extract remote party information based on transaction type + let remoteName = ''; + let remoteIBAN = ''; + let remoteBankId = ''; + + if (isDebit) { + // For debit transactions, we want the creditor (receiving party) + const creditorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Cdtr>/); + remoteName = creditorNameMatch ? creditorNameMatch[1] : ''; + + const creditorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/CdtrAcct>/); + remoteIBAN = creditorIbanMatch ? creditorIbanMatch[1] : ''; + + // For debit, get creditor's bank BIC + const creditorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/CdtrAgt>/); + remoteBankId = creditorBicMatch ? creditorBicMatch[1] : ''; + } else { + // For credit transactions, we want the debtor (sending party) + const debtorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Dbtr>/); + remoteName = debtorNameMatch ? debtorNameMatch[1] : ''; + + const debtorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/DbtrAcct>/); + remoteIBAN = debtorIbanMatch ? debtorIbanMatch[1] : ''; + + // For credit, get debtor's bank BIC + const debtorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/DbtrAgt>/); + remoteBankId = debtorBicMatch ? debtorBicMatch[1] : ''; + } + + // Extract bank transaction code structure (BkTxCd) + const bkTxCd = this.parseBankTransactionCode(entryXml); + + return { + valueDate: parsedValueDate, + entryDate, + fundsCode: bkTxCd.domainCode || creditDebitInd || '', + amount, + transactionType: bkTxCd.familyCode || '', + customerReference: endToEndId, + bankReference: accountServicerRef, + transactionCode: bkTxCd.subFamilyCode || '', + purpose: remittanceInfo, + remoteName, + remoteAccountNumber: remoteIBAN, + remoteBankId, + e2eReference: endToEndId, + mandateReference: mandateId, + additionalInformation: additionalEntryInfo, + bookingText: additionalEntryInfo, + }; + } catch (error) { + console.warn('Failed to parse CAMT transaction entry:', error); + return null; + } + } + + private parseDate(dateStr: string): Date { + // Parse ISO date format (YYYY-MM-DD) + if (dateStr.length === 10 && dateStr.includes('-')) { + return new Date(dateStr + 'T12:00:00'); // Set time to noon to avoid timezone issues + } + + // Parse CAMT date format (YYYYMMDD) + if (dateStr.length === 8) { + const year = parseInt(dateStr.substring(0, 4), 10); + const month = parseInt(dateStr.substring(4, 6), 10) - 1; // Month is 0-based + const day = parseInt(dateStr.substring(6, 8), 10); + return new Date(year, month, day, 12); + } + + return new Date(dateStr); + } + + private extractTagValue(xml: string, tagName: string): string | undefined { + const pattern = new RegExp(`<${tagName}>([^<]*)<\\/${tagName}>`, 'i'); + const match = xml.match(pattern); + return match ? match[1] : undefined; + } + + private extractNestedTagValue(xml: string, parentTag: string, childTag: string): string | undefined { + const parentPattern = new RegExp(`<${parentTag}[\\s\\S]*?<\\/${parentTag}>`, 'i'); + const parentMatch = xml.match(parentPattern); + if (!parentMatch) { + return undefined; + } + return this.extractTagValue(parentMatch[0], childTag); + } + + private parseBankTransactionCode(entryXml: string): { + domainCode?: string; + familyCode?: string; + subFamilyCode?: string; + } { + // Extract the entire BkTxCd block + const bkTxCdMatch = entryXml.match(/[\s\S]*?<\/BkTxCd>/); + if (!bkTxCdMatch) { + return {}; + } + + const bkTxCdXml = bkTxCdMatch[0]; + + // Extract Domain Code (first level - e.g., "PMNT") + const domainCode = this.extractNestedTagValue(bkTxCdXml, 'Domn', 'Cd'); + + // Extract Family Code (second level - e.g., "CCRD") + const familyCode = this.extractNestedTagValue(bkTxCdXml, 'Fmly', 'Cd'); + + // Extract SubFamily Code (third level - e.g., "POSD") + const subFamilyCode = this.extractTagValue(bkTxCdXml, 'SubFmlyCd'); + + return { + domainCode, + familyCode, + subFamilyCode, + }; + } +} diff --git a/src/client.ts b/src/client.ts index 154f17b..c24a605 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,8 @@ import { Dialog } from './dialog.js'; import { HKTAB } from './segments/HKTAB.js'; -import { StatementResponse, StatementInteraction } from './interactions/statementInteraction.js'; +import { StatementResponse } from './interactions/customerInteraction.js'; +import { StatementInteractionMT940 } from './interactions/statementInteractionMT940.js'; +import { StatementInteractionCAMT } from './interactions/statementInteractionCAMT.js'; import { AccountBalanceResponse, BalanceInteraction } from './interactions/balanceInteraction.js'; import { PortfolioResponse, PortfolioInteraction } from './interactions/portfolioInteraction.js'; import { FinTSConfig } from './config.js'; @@ -9,10 +11,11 @@ import { TanMediaInteraction, TanMediaResponse } from './interactions/tanMediaIn import { TanMethod } from './tanMethod.js'; import { HKSAL } from './segments/HKSAL.js'; import { HKKAZ } from './segments/HKKAZ.js'; +import { HKCAZ } from './segments/HKCAZ.js'; import { HKWPD } from './segments/HKWPD.js'; import { DKKKU } from './segments/DKKKU.js'; import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; -import {CreditCardStatementInteraction} from "./interactions/creditcardStatementInteraction.js"; +import { CreditCardStatementInteraction } from './interactions/creditcardStatementInteraction.js'; export interface SynchronizeResponse extends InitResponse {} @@ -128,9 +131,16 @@ export class FinTSClient { * @returns true if the bank (and account) supports fetching account statements */ canGetAccountStatements(accountNumber?: string): boolean { - return accountNumber - ? this.config.isAccountTransactionSupported(accountNumber, HKKAZ.Id) - : this.config.isTransactionSupported(HKKAZ.Id); + if (accountNumber) { + // Check if either CAMT or MT940 is supported for this account + return ( + this.config.isAccountTransactionSupported(accountNumber, HKCAZ.Id) || + this.config.isAccountTransactionSupported(accountNumber, HKKAZ.Id) + ); + } else { + // Check if either CAMT or MT940 is supported by the bank + return this.config.isTransactionSupported(HKCAZ.Id) || this.config.isTransactionSupported(HKKAZ.Id); + } } /** @@ -138,10 +148,31 @@ export class FinTSClient { * @param accountNumber - the account number to fetch the statements for, must be an account available in the config.baningInformation.UPD.accounts * @param from - an optional start date of the period to fetch the statements for * @param to - an optional end date of the period to fetch the statements for + * @param preferCamt - whether to prefer CAMT format over MT940 when both are supported (default: true) * @returns an account statements response containing an array of statements */ - async getAccountStatements(accountNumber: string, from?: Date, to?: Date): Promise { - return this.startCustomerOrderInteraction(new StatementInteraction(accountNumber, from, to)); + async getAccountStatements( + accountNumber: string, + from?: Date, + to?: Date, + preferCamt: boolean = true + ): Promise { + // Check what formats the bank supports + const camtSupported = this.config.isAccountTransactionSupported(accountNumber, 'HKCAZ'); + const mt940Supported = this.config.isAccountTransactionSupported(accountNumber, 'HKKAZ'); + + if (!camtSupported && !mt940Supported) { + throw Error(`Account ${accountNumber} does not support account statements`); + } + + // Choose format based on support and preference + const useCAMT = (preferCamt && camtSupported) || (!mt940Supported && camtSupported); + + if (useCAMT) { + return this.startCustomerOrderInteraction(new StatementInteractionCAMT(accountNumber, from, to)); + } else { + return this.startCustomerOrderInteraction(new StatementInteractionMT940(accountNumber, from, to)); + } } /** @@ -195,7 +226,6 @@ export class FinTSClient { return this.continueCustomerInteractionWithTan(tanReference, tan); } - /** * Checks if the bank supports fetching credit card statements in general or for the given account number * @param accountNumber when the account number is provided, checks if the account supports fetching of statements diff --git a/src/config.ts b/src/config.ts index 592f9c6..ff2da00 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { TanMethod } from './tanMethod.js'; import { BankingInformation } from './bankingInformation.js'; import { getSegmentDefinition } from './segments/registry.js'; +import { BankAccount } from './bankAccount.js'; /** * Configuration for the FinTS client @@ -240,7 +241,7 @@ export class FinTSConfig { * Gets the bank account information for a specific account number * @param accountNumber The account number */ - getBankAccount(accountNumber: string) { + getBankAccount(accountNumber: string): BankAccount { const bankAccount = this.bankingInformation.upd?.bankAccounts.find((a) => a.accountNumber === accountNumber); if (!bankAccount) { diff --git a/src/httpClient.ts b/src/httpClient.ts index 9f9dfd2..62b8705 100644 --- a/src/httpClient.ts +++ b/src/httpClient.ts @@ -1,43 +1,55 @@ import { CustomerMessage, CustomerOrderMessage, Message } from './message.js'; export class HttpClient { - constructor(public url: string, public debug = false) {} - - async sendMessage(message: CustomerMessage): Promise { - const encodedMessage = message.encode(); - const requestBuffer = Buffer.from(encodedMessage); - - if (this.debug) { - console.log('Request Message:\n' + message.toString()); - } - - const response = await fetch(this.url, { - method: 'POST', - headers: { 'Content-Type': 'text/plain' }, - body: requestBuffer.toString('base64'), - }); - - if (response.ok) { - const responseBuffer = Buffer.from(await response.text(), 'base64'); - const responseText = responseBuffer.toString('latin1'); - - try { - const customerOrderMessage = message as CustomerOrderMessage; - const responseMessage = Message.decode( - responseText, - customerOrderMessage.supportsPartedResponseSegments ? customerOrderMessage.orderResponseSegId : undefined - ); - if (this.debug) { - console.log('Response Message:\n' + responseMessage.toString(true) + '\n'); - } - return responseMessage; - } catch (error) { - console.error('Error decoding response message:', error); - console.error('Response Message Content:\n', responseText.split("'").join('\n')); - throw error; - } - } else { - throw Error(`Request failed with status code ${response.status}: ${await response.text()}`); - } - } + constructor(public url: string, public debug = false, public debugRaw = false) {} + + async sendMessage(message: CustomerMessage): Promise { + const encodedMessage = message.encode(); + const requestBuffer = Buffer.from(encodedMessage); + + if (this.debug) { + console.log('Request Message:\n'); + + if (this.debugRaw) { + console.log(encodedMessage.split("'").join('\n')); + } else { + console.log(message.toString()); + } + } + + const response = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: requestBuffer.toString('base64'), + }); + + if (response.ok) { + const responseBuffer = Buffer.from(await response.text(), 'base64'); + const responseText = responseBuffer.toString('latin1'); + + try { + const customerOrderMessage = message as CustomerOrderMessage; + const responseMessage = Message.decode( + responseText, + customerOrderMessage.supportsPartedResponseSegments ? customerOrderMessage.orderResponseSegId : undefined + ); + if (this.debug) { + console.log('Response Message:\n'); + + if (this.debugRaw) { + console.log(responseText.split("'").join('\n')); + } else { + console.log('Response Message:\n' + responseMessage.toString(true) + '\n'); + } + } + return responseMessage; + } catch (error) { + console.error('Error decoding response message:', error); + console.error('Response Message Content:\n', responseText.split("'").join('\n')); + throw error; + } + } else { + throw Error(`Request failed with status code ${response.status}: ${await response.text()}`); + } + } } diff --git a/src/index.ts b/src/index.ts index 8ec7199..df3f41a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ export * from './client.js'; export * from './config.js'; export { ClientResponse } from './interactions/customerInteraction.js'; export { AccountBalanceResponse } from './interactions/balanceInteraction.js'; -export { StatementResponse } from './interactions/statementInteraction.js'; +export { StatementResponse } from './interactions/customerInteraction.js'; export { PortfolioResponse } from './interactions/portfolioInteraction.js'; export * from './segment.js'; export * from './message.js'; @@ -18,5 +18,6 @@ export * from './bankingInformation.js'; export * from './accountBalance.js'; export * from './bpd.js'; export * from './upd.js'; +export * from './statement.js'; export * from './mt940parser.js'; export * from './mt535parser.js'; diff --git a/src/interactions/customerInteraction.ts b/src/interactions/customerInteraction.ts index 57eede6..1c77a60 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -3,6 +3,7 @@ import { Dialog } from '../dialog.js'; import { FinTSConfig } from '../config.js'; import { Message } from '../message.js'; import { Segment } from '../segment.js'; +import { Statement } from '../statement.js'; import { HITAN, HITANSegment } from '../segments/HITAN.js'; import { HNHBK, HNHBKSegment } from '../segments/HNHBK.js'; @@ -28,6 +29,10 @@ export interface ClientResponse { tanMediaName?: string; } +export interface StatementResponse extends ClientResponse { + statements: Statement[]; +} + export abstract class CustomerInteraction { dialog?: Dialog; diff --git a/src/interactions/statementInteraction.ts b/src/interactions/statementInteraction.ts deleted file mode 100644 index 9826567..0000000 --- a/src/interactions/statementInteraction.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './customerInteraction.js'; -import { Message } from '../message.js'; -import { HKKAZ, HKKAZSegment } from '../segments/HKKAZ.js'; -import { HIKAZ, HIKAZSegment } from '../segments/HIKAZ.js'; -import { Mt940Parser, Statement } from '../mt940parser.js'; -import { Segment } from '../segment.js'; -import { FinTSConfig } from '../config.js'; - -export interface StatementResponse extends ClientResponse { - statements: Statement[]; -} - -export class StatementInteraction extends CustomerOrderInteraction { - constructor(public accountNumber: string, public from?: Date, public to?: Date) { - super(HKKAZ.Id, HIKAZ.Id); - } - - createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); - if (!init.isAccountTransactionSupported(this.accountNumber, this.segId)) { - throw Error(`Account ${this.accountNumber} does not support business transaction '${this.segId}'`); - } - - const account = { ...bankAccount, iban: undefined }; - - const version = init.getMaxSupportedTransactionVersion(HKKAZ.Id); - - if (!version) { - throw Error(`There is no supported version for business transaction '${HKKAZ.Id}`); - } - - const hkkaz: HKKAZSegment = { - header: { segId: HKKAZ.Id, segNr: 0, version: version }, - account, - allAccounts: false, - from: this.from, - to: this.to, - }; - - return [hkkaz]; - } - - handleResponse(response: Message, clientResponse: StatementResponse) { - const hikaz = response.findSegment(HIKAZ.Id); - if (hikaz) { - const parser = new Mt940Parser(hikaz.bookedTransactions); - clientResponse.statements = parser.parse(); - } else { - clientResponse.statements = []; - } - } -} diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts new file mode 100644 index 0000000..ed35161 --- /dev/null +++ b/src/interactions/statementInteractionCAMT.ts @@ -0,0 +1,56 @@ +import { ClientResponse, CustomerOrderInteraction, StatementResponse } from './customerInteraction.js'; +import { Message } from '../message.js'; +import { HKCAZ, HKCAZSegment } from '../segments/HKCAZ.js'; +import { HICAZ, HICAZSegment } from '../segments/HICAZ.js'; +import { CamtParser } from '../camtParser.js'; +import { Statement } from '../statement.js'; +import { Segment } from '../segment.js'; +import { FinTSConfig } from '../config.js'; + +export class StatementInteractionCAMT extends CustomerOrderInteraction { + private acceptedCamtFormats: string[] = ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02']; + + constructor(public accountNumber: string, public from?: Date, public to?: Date) { + super(HKCAZ.Id, HICAZ.Id); + } + + createSegments(init: FinTSConfig): Segment[] { + const bankAccount = init.getBankAccount(this.accountNumber); + const version = init.getMaxSupportedTransactionVersion(HKCAZ.Id); + if (!version) { + throw Error(`There is no supported version for business transaction '${HKCAZ.Id}'`); + } + + const hkcaz: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 0, version: version }, + account: bankAccount, + acceptedCamtFormats: this.acceptedCamtFormats, + allAccounts: false, + from: this.from, + to: this.to, + }; + + return [hkcaz]; + } + + handleResponse(response: Message, clientResponse: StatementResponse) { + const hicaz = response.findSegment(HICAZ.Id); + if (hicaz && hicaz.bookedTransactions && hicaz.bookedTransactions.length > 0) { + try { + // Parse all CAMT messages (one per booking day) and combine statements + const allStatements: Statement[] = []; + for (const camtMessage of hicaz.bookedTransactions) { + const parser = new CamtParser(camtMessage); + const statements = parser.parse(); + allStatements.push(...statements); + } + clientResponse.statements = allStatements; + } catch (error) { + console.warn('CAMT parsing failed:', error); + clientResponse.statements = []; + } + } else { + clientResponse.statements = []; + } + } +} diff --git a/src/interactions/statementInteractionMT940.ts b/src/interactions/statementInteractionMT940.ts new file mode 100644 index 0000000..43d129c --- /dev/null +++ b/src/interactions/statementInteractionMT940.ts @@ -0,0 +1,48 @@ +import { CustomerOrderInteraction, StatementResponse } from './customerInteraction.js'; +import { Message } from '../message.js'; +import { HKKAZ, HKKAZSegment } from '../segments/HKKAZ.js'; +import { HIKAZ, HIKAZSegment } from '../segments/HIKAZ.js'; +import { Mt940Parser } from '../mt940parser.js'; +import { Segment } from '../segment.js'; +import { FinTSConfig } from '../config.js'; + +export class StatementInteractionMT940 extends CustomerOrderInteraction { + constructor(public accountNumber: string, public from?: Date, public to?: Date) { + super(HKKAZ.Id, HIKAZ.Id); + } + + createSegments(init: FinTSConfig): Segment[] { + const bankAccount = init.getBankAccount(this.accountNumber); + const account = { ...bankAccount, iban: undefined }; + const version = init.getMaxSupportedTransactionVersion(HKKAZ.Id); + + if (!version) { + throw Error(`There is no supported version for business transaction '${HKKAZ.Id}'`); + } + + const hkkaz: HKKAZSegment = { + header: { segId: HKKAZ.Id, segNr: 0, version: version }, + account, + allAccounts: false, + from: this.from, + to: this.to, + }; + + return [hkkaz]; + } + + handleResponse(response: Message, clientResponse: StatementResponse) { + const hikaz = response.findSegment(HIKAZ.Id); + if (hikaz && hikaz.bookedTransactions) { + try { + const parser = new Mt940Parser(hikaz.bookedTransactions); + clientResponse.statements = parser.parse(); + } catch (error) { + console.warn('MT940 parsing failed:', error); + clientResponse.statements = []; + } + } else { + clientResponse.statements = []; + } + } +} diff --git a/src/mt940parser.ts b/src/mt940parser.ts index cbd8a11..7ed4dff 100644 --- a/src/mt940parser.ts +++ b/src/mt940parser.ts @@ -1,382 +1,345 @@ -export interface Statement { - transactionReference?: string; - relatedReference?: string; - account?: string; - number?: string; - openingBalance: Balance; - transactions: Transaction[]; - closingBalance: Balance; - availableBalance?: Balance; - forwardBalances?: Balance[]; -} - -export interface Transaction { - valueDate: Date; - entryDate: Date; - fundsCode: string; - amount: number; - transactionType: string; - customerReference: string; - bankReference: string; - transactionCode?: string; - bookingText?: string; - primeNotesNr?: string; - purpose?: string; - remoteBankId?: string; - remoteAccountNumber?: string; - remoteName?: string; - remoteIdentifier?: string; - client?: string; - e2eReference?: string; - mandateReference?: string; - textKeyExtension?: string; - additionalInformation?: string; -} +import { Statement, Transaction, Balance } from './statement.js'; -export interface Balance { - date: Date; - currency: string; - value: number; -} +export { Statement, Transaction, Balance }; export enum TokenType { - Tag = 'Tag', - SubTag = 'SubTag', - PurposeTag = 'PurposeTag', - SingleAlpha = 'SingleAlpha', - StatementNumber = 'StatementNumber', - CustomerReference = 'CustomerReference', - BankReference = 'BankReference', - Date = 'Date', - ShortDate = 'ShortDate', - CreditDebit = 'CreditDebit', - Decimal = 'Decimal', - Currency = 'Currency', - TextToEndOfLine = 'TextToEndOfLine', - TextToNextSubTag = 'TextToNextSubTag', - TextToNextPurposeTag = 'TextToNextPurposeTag', - NextNonTagLine = 'NextNonTagLine', - TransactionType = 'TransactionType', - TransactionCode = 'TransactionCode', - WhiteSpace = 'WhiteSpace', + Tag = 'Tag', + SubTag = 'SubTag', + PurposeTag = 'PurposeTag', + SingleAlpha = 'SingleAlpha', + StatementNumber = 'StatementNumber', + CustomerReference = 'CustomerReference', + BankReference = 'BankReference', + Date = 'Date', + ShortDate = 'ShortDate', + CreditDebit = 'CreditDebit', + Decimal = 'Decimal', + Currency = 'Currency', + TextToEndOfLine = 'TextToEndOfLine', + TextToNextSubTag = 'TextToNextSubTag', + TextToNextPurposeTag = 'TextToNextPurposeTag', + NextNonTagLine = 'NextNonTagLine', + TransactionType = 'TransactionType', + TransactionCode = 'TransactionCode', + WhiteSpace = 'WhiteSpace', } const tokens: { [key in TokenType]: RegExp } = { - Tag: /^:\d\d[A-Z]?:/, - SubTag: /^\?\d\d/, - PurposeTag: /^[A-Z]{4}\+/, - SingleAlpha: /^[A-Z]/, - StatementNumber: /^\d+\/?\d*/, - CustomerReference: /^[^\/\r\n]+/, - BankReference: /^\/\/([^\r\n]+)/, - CreditDebit: /^C|D|RC|RD/, - Date: /^\d{6}/, - ShortDate: /^\d{4}/, - Decimal: /^\d+,\d*/, - Currency: /^[A-Z]{3}/, - TextToEndOfLine: /^[^\r\n]+/, - TextToNextSubTag: /^[^\?]+/, - TextToNextPurposeTag: /^(.*?)(?=\s[A-Z]{4}\+|$)/, - NextNonTagLine: /^\r\n([^:][^\r\n]*)/, - TransactionType: /^[A-Z][0-9A-Z]{3}/, - TransactionCode: /^\d{3}/, - WhiteSpace: /^\s+/, + Tag: /^:\d\d[A-Z]?:/, + SubTag: /^\?\d\d/, + PurposeTag: /^[A-Z]{4}\+/, + SingleAlpha: /^[A-Z]/, + StatementNumber: /^\d+\/?\d*/, + CustomerReference: /^[^\/\r\n]+/, + BankReference: /^\/\/([^\r\n]+)/, + CreditDebit: /^C|D|RC|RD/, + Date: /^\d{6}/, + ShortDate: /^\d{4}/, + Decimal: /^\d+,\d*/, + Currency: /^[A-Z]{3}/, + TextToEndOfLine: /^[^\r\n]+/, + TextToNextSubTag: /^[^\?]+/, + TextToNextPurposeTag: /^(.*?)(?=\s[A-Z]{4}\+|$)/, + NextNonTagLine: /^\r\n([^:][^\r\n]*)/, + TransactionType: /^[A-Z][0-9A-Z]{3}/, + TransactionCode: /^\d{3}/, + WhiteSpace: /^\s+/, }; export class Mt940Parser { - tokenizer: Mt940Tokenizer; - - statements: Statement[] = []; - currentStatement: Partial = { - transactions: [], - }; - currentTransaction: Transaction | undefined; - - constructor(private input: string) { - this.tokenizer = new Mt940Tokenizer(input); - } - - parse(): Statement[] { - while (!this.tokenizer.isAtEnd()) { - if (this.tokenizer.parseNextToken(TokenType.WhiteSpace, false)) { - continue; - } - - let tag = this.tokenizer.parseNextToken(TokenType.Tag, false); - - if (tag) { - switch (tag) { - case ':20:': - this.currentStatement = { - transactions: [], - }; - this.statements.push(this.currentStatement as Statement); - this.currentStatement.transactionReference = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); - break; - case ':21:': - this.currentStatement.relatedReference = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); - break; - case ':25:': - this.currentStatement.account = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); - break; - case ':28C:': - this.currentStatement.number = this.tokenizer.parseNextToken(TokenType.StatementNumber, true); - break; - case ':60F:': - this.currentStatement.openingBalance = this.parseBalance(); - break; - case ':62F:': - this.currentStatement.closingBalance = this.parseBalance(); - break; - case ':64:': - this.currentStatement.availableBalance = this.parseBalance(); - break; - case ':65:': - if (!this.currentStatement.forwardBalances) { - this.currentStatement.forwardBalances = []; - } - this.currentStatement.forwardBalances.push(this.parseBalance()); - break; - case ':61:': - const valueDate = this.parseDate(true); - let entryDate = valueDate; - let entryDateString = this.tokenizer.parseNextToken(TokenType.ShortDate, false); - if (entryDateString) { - const valueYear = valueDate.getFullYear(); - const valueMonth = valueDate.getMonth() + 1; - const entryMonth = parseInt(entryDateString.substring(0, 2)); - const entryDay = parseInt(entryDateString.substring(2, 4)); - const entryYear = entryMonth <= valueMonth ? valueYear : valueYear - 1; - entryDate = new Date(entryYear, entryMonth - 1, entryDay); - } else { - entryDate = valueDate; - } - - const creditDebit = this.tokenizer.parseNextToken(TokenType.CreditDebit, true); - const fundsCode = this.tokenizer.parseNextToken(TokenType.SingleAlpha, false); - const amount = this.parseAmount(creditDebit, true); - const transactionType = this.tokenizer.parseNextToken(TokenType.TransactionType, true); - const customerReference = this.tokenizer.parseNextToken(TokenType.CustomerReference, true); - const bankReference = this.tokenizer.parseNextToken(TokenType.BankReference, false); - const additionalInformation = this.tokenizer.parseNextToken(TokenType.NextNonTagLine, false); - - this.currentTransaction = { - valueDate: valueDate, - entryDate: entryDate, - fundsCode: fundsCode, - amount: amount, - transactionType: transactionType, - customerReference: customerReference, - bankReference: bankReference, - additionalInformation: additionalInformation, - }; - - this.currentStatement.transactions!.push(this.currentTransaction); - break; - case ':86:': - let infoToAccountOwner = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); - - let nextLine; - do { - nextLine = this.tokenizer.parseNextToken(TokenType.NextNonTagLine, false); - if (nextLine) { - infoToAccountOwner += nextLine; - } - } while (nextLine); - - this.parseInfoToAccountOwner(infoToAccountOwner); - break; - default: - this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); - break; - } - } else { - this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); - } - } - - return this.statements; - } - - parseInfoToAccountOwner(infoToAccountOwner: string) { - if (!this.currentTransaction) { - return; - } - - const subFieldTokenizer = new Mt940Tokenizer(infoToAccountOwner); - - this.currentTransaction.transactionCode = subFieldTokenizer.parseNextToken(TokenType.TransactionCode, false); - - let subTag; - do { - subTag = subFieldTokenizer.parseNextToken(TokenType.SubTag, false); - if (subTag) { - switch (subTag) { - case '?00': - this.currentTransaction.bookingText = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); - break; - case '?10': - this.currentTransaction.primeNotesNr = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); - break; - case '?20': - case '?21': - case '?22': - case '?23': - case '?24': - case '?25': - case '?26': - case '?27': - case '?28': - case '?29': - case '?60': - case '?61': - case '?62': - case '?63': - if (!this.currentTransaction.purpose) { - this.currentTransaction.purpose = ''; - } - - const purposeTag = subFieldTokenizer.parseNextToken(TokenType.PurposeTag, false); - - if (purposeTag) { - if (this.currentTransaction.purpose) { - this.currentTransaction.purpose += ' '; - } - this.currentTransaction.purpose += purposeTag; - } - - this.currentTransaction.purpose += subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); - break; - case '?30': - this.currentTransaction.remoteBankId = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); - break; - case '?31': - this.currentTransaction.remoteAccountNumber = subFieldTokenizer.parseNextToken( - TokenType.TextToNextSubTag, - true - ); - break; - case '?32': - case '?33': - if (!this.currentTransaction.remoteName) { - this.currentTransaction.remoteName = ''; - } - this.currentTransaction.remoteName += subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); - break; - case '?34': - this.currentTransaction.textKeyExtension = subFieldTokenizer.parseNextToken( - TokenType.TextToNextSubTag, - true - ); - break; - default: - subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, false); - break; - } - } - } while (subTag); - - this.parsePurpose(this.currentTransaction); - } - - parsePurpose(transaction: Transaction) { - const purposeTokenizer = new Mt940Tokenizer(transaction.purpose ?? ''); - - do { - const purposeTag = purposeTokenizer.parseNextToken(TokenType.PurposeTag, false); - if (purposeTag) { - let text = purposeTokenizer.parseNextToken(TokenType.TextToNextPurposeTag, false); - if (!text) { - text = purposeTokenizer.parseNextToken(TokenType.TextToEndOfLine, false); - } - - switch (purposeTag) { - case 'EREF+': - transaction.e2eReference = text; - break; - case 'KREF+': - transaction.customerReference = text; - break; - case 'MREF+': - transaction.mandateReference = text; - break; - case 'CRED+': - transaction.remoteIdentifier = text; - break; - case 'DEBT+': - transaction.remoteIdentifier = text; - break; - case 'ABWA+': - transaction.client = text; - break; - case 'SVWZ+': - transaction.purpose = text; - break; - default: - break; - } - } else { - break; - } - purposeTokenizer.parseNextToken(TokenType.WhiteSpace, false); - } while (!purposeTokenizer.isAtEnd()); - } - - parseBalance(): Balance { - const creditDebit = this.tokenizer.parseNextToken(TokenType.CreditDebit, true); - const date = this.parseDate(true); - const currency = this.tokenizer.parseNextToken(TokenType.Currency, true); - const amount = this.tokenizer.parseNextToken(TokenType.Decimal, true); - - return { - date: date, - currency: currency, - value: parseFloat(amount.replace(',', '.')) * (creditDebit === 'D' ? -1 : 1), - }; - } - - parseDate(isMandatory = true): Date { - const date = this.tokenizer.parseNextToken(TokenType.Date, isMandatory); - - const year = parseInt(date.substring(0, 2)) + 2000; - const month = parseInt(date.substring(2, 4)); - const day = parseInt(date.substring(4, 6)); - - return new Date(year, month - 1, day); - } - - parseAmount(creditDebit: string, isMandatory = true): number { - const amount = this.tokenizer.parseNextToken(TokenType.Decimal, isMandatory); - return parseFloat(amount.replace(',', '.')) * (creditDebit === 'D' || creditDebit == 'RC' ? -1 : 1); - } + tokenizer: Mt940Tokenizer; + + statements: Statement[] = []; + currentStatement: Partial = { + transactions: [], + }; + currentTransaction: Transaction | undefined; + + constructor(private input: string) { + this.tokenizer = new Mt940Tokenizer(input); + } + + parse(): Statement[] { + while (!this.tokenizer.isAtEnd()) { + if (this.tokenizer.parseNextToken(TokenType.WhiteSpace, false)) { + continue; + } + + let tag = this.tokenizer.parseNextToken(TokenType.Tag, false); + + if (tag) { + switch (tag) { + case ':20:': + this.currentStatement = { + transactions: [], + }; + this.statements.push(this.currentStatement as Statement); + this.currentStatement.transactionReference = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); + break; + case ':21:': + this.currentStatement.relatedReference = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); + break; + case ':25:': + this.currentStatement.account = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); + break; + case ':28C:': + this.currentStatement.number = this.tokenizer.parseNextToken(TokenType.StatementNumber, true); + break; + case ':60F:': + this.currentStatement.openingBalance = this.parseBalance(); + break; + case ':62F:': + this.currentStatement.closingBalance = this.parseBalance(); + break; + case ':64:': + this.currentStatement.availableBalance = this.parseBalance(); + break; + case ':65:': + if (!this.currentStatement.forwardBalances) { + this.currentStatement.forwardBalances = []; + } + this.currentStatement.forwardBalances.push(this.parseBalance()); + break; + case ':61:': + const valueDate = this.parseDate(true); + let entryDate = valueDate; + let entryDateString = this.tokenizer.parseNextToken(TokenType.ShortDate, false); + if (entryDateString) { + const valueYear = valueDate.getFullYear(); + const valueMonth = valueDate.getMonth() + 1; + const entryMonth = parseInt(entryDateString.substring(0, 2)); + const entryDay = parseInt(entryDateString.substring(2, 4)); + const entryYear = entryMonth <= valueMonth ? valueYear : valueYear - 1; + entryDate = new Date(entryYear, entryMonth - 1, entryDay); + } else { + entryDate = valueDate; + } + + const creditDebit = this.tokenizer.parseNextToken(TokenType.CreditDebit, true); + const fundsCode = this.tokenizer.parseNextToken(TokenType.SingleAlpha, false); + const amount = this.parseAmount(creditDebit, true); + const transactionType = this.tokenizer.parseNextToken(TokenType.TransactionType, true); + const customerReference = this.tokenizer.parseNextToken(TokenType.CustomerReference, true); + const bankReference = this.tokenizer.parseNextToken(TokenType.BankReference, false); + const additionalInformation = this.tokenizer.parseNextToken(TokenType.NextNonTagLine, false); + + this.currentTransaction = { + valueDate: valueDate, + entryDate: entryDate, + fundsCode: fundsCode, + amount: amount, + transactionType: transactionType, + customerReference: customerReference, + bankReference: bankReference, + additionalInformation: additionalInformation, + }; + + this.currentStatement.transactions!.push(this.currentTransaction); + break; + case ':86:': + let infoToAccountOwner = this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, true); + + let nextLine; + do { + nextLine = this.tokenizer.parseNextToken(TokenType.NextNonTagLine, false); + if (nextLine) { + infoToAccountOwner += nextLine; + } + } while (nextLine); + + this.parseInfoToAccountOwner(infoToAccountOwner); + break; + default: + this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); + break; + } + } else { + this.tokenizer.parseNextToken(TokenType.TextToEndOfLine, false); + } + } + + return this.statements; + } + + parseInfoToAccountOwner(infoToAccountOwner: string) { + if (!this.currentTransaction) { + return; + } + + const subFieldTokenizer = new Mt940Tokenizer(infoToAccountOwner); + + this.currentTransaction.transactionCode = subFieldTokenizer.parseNextToken(TokenType.TransactionCode, false); + + let subTag; + do { + subTag = subFieldTokenizer.parseNextToken(TokenType.SubTag, false); + if (subTag) { + switch (subTag) { + case '?00': + this.currentTransaction.bookingText = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); + break; + case '?10': + this.currentTransaction.primeNotesNr = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); + break; + case '?20': + case '?21': + case '?22': + case '?23': + case '?24': + case '?25': + case '?26': + case '?27': + case '?28': + case '?29': + case '?60': + case '?61': + case '?62': + case '?63': + if (!this.currentTransaction.purpose) { + this.currentTransaction.purpose = ''; + } + + const purposeTag = subFieldTokenizer.parseNextToken(TokenType.PurposeTag, false); + + if (purposeTag) { + if (this.currentTransaction.purpose) { + this.currentTransaction.purpose += ' '; + } + this.currentTransaction.purpose += purposeTag; + } + + this.currentTransaction.purpose += subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); + break; + case '?30': + this.currentTransaction.remoteBankId = subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); + break; + case '?31': + this.currentTransaction.remoteAccountNumber = subFieldTokenizer.parseNextToken( + TokenType.TextToNextSubTag, + true + ); + break; + case '?32': + case '?33': + if (!this.currentTransaction.remoteName) { + this.currentTransaction.remoteName = ''; + } + this.currentTransaction.remoteName += subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, true); + break; + case '?34': + this.currentTransaction.textKeyExtension = subFieldTokenizer.parseNextToken( + TokenType.TextToNextSubTag, + true + ); + break; + default: + subFieldTokenizer.parseNextToken(TokenType.TextToNextSubTag, false); + break; + } + } + } while (subTag); + + this.parsePurpose(this.currentTransaction); + } + + parsePurpose(transaction: Transaction) { + const purposeTokenizer = new Mt940Tokenizer(transaction.purpose ?? ''); + + do { + const purposeTag = purposeTokenizer.parseNextToken(TokenType.PurposeTag, false); + if (purposeTag) { + let text = purposeTokenizer.parseNextToken(TokenType.TextToNextPurposeTag, false); + if (!text) { + text = purposeTokenizer.parseNextToken(TokenType.TextToEndOfLine, false); + } + + switch (purposeTag) { + case 'EREF+': + transaction.e2eReference = text; + break; + case 'KREF+': + transaction.customerReference = text; + break; + case 'MREF+': + transaction.mandateReference = text; + break; + case 'CRED+': + transaction.remoteIdentifier = text; + break; + case 'DEBT+': + transaction.remoteIdentifier = text; + break; + case 'ABWA+': + transaction.client = text; + break; + case 'SVWZ+': + transaction.purpose = text; + break; + default: + break; + } + } else { + break; + } + purposeTokenizer.parseNextToken(TokenType.WhiteSpace, false); + } while (!purposeTokenizer.isAtEnd()); + } + + parseBalance(): Balance { + const creditDebit = this.tokenizer.parseNextToken(TokenType.CreditDebit, true); + const date = this.parseDate(true); + const currency = this.tokenizer.parseNextToken(TokenType.Currency, true); + const amount = this.tokenizer.parseNextToken(TokenType.Decimal, true); + + return { + date: date, + currency: currency, + value: parseFloat(amount.replace(',', '.')) * (creditDebit === 'D' ? -1 : 1), + }; + } + + parseDate(isMandatory = true): Date { + const date = this.tokenizer.parseNextToken(TokenType.Date, isMandatory); + + const year = parseInt(date.substring(0, 2)) + 2000; + const month = parseInt(date.substring(2, 4)); + const day = parseInt(date.substring(4, 6)); + + return new Date(year, month - 1, day); + } + + parseAmount(creditDebit: string, isMandatory = true): number { + const amount = this.tokenizer.parseNextToken(TokenType.Decimal, isMandatory); + return parseFloat(amount.replace(',', '.')) * (creditDebit === 'D' || creditDebit == 'RC' ? -1 : 1); + } } export class Mt940Tokenizer { - position = 0; - lastToken: string = ''; + position = 0; + lastToken: string = ''; - constructor(private input: string) {} + constructor(private input: string) {} - parseNextToken(type: TokenType, isMandatory: boolean): string { - let matched = this.match(tokens[type]); - if (matched) { - this.position += matched[0].length; - this.lastToken = matched.length > 1 ? matched[1] : matched[0]; - return this.lastToken; - } + parseNextToken(type: TokenType, isMandatory: boolean): string { + let matched = this.match(tokens[type]); + if (matched) { + this.position += matched[0].length; + this.lastToken = matched.length > 1 ? matched[1] : matched[0]; + return this.lastToken; + } - if (isMandatory) { - throw new SyntaxError(`Expected ${type} token at position ${this.position}, after '${this.lastToken}'...`); - } + if (isMandatory) { + throw new SyntaxError(`Expected ${type} token at position ${this.position}, after '${this.lastToken}'...`); + } - return ''; - } + return ''; + } - match(regExp: RegExp): RegExpExecArray | null { - return regExp.exec(this.input.substring(this.position)); - } + match(regExp: RegExp): RegExpExecArray | null { + return regExp.exec(this.input.substring(this.position)); + } - isAtEnd(): boolean { - return this.position >= this.input.length; - } + isAtEnd(): boolean { + return this.position >= this.input.length; + } } diff --git a/src/segments/HICAZ.ts b/src/segments/HICAZ.ts new file mode 100644 index 0000000..cb10b64 --- /dev/null +++ b/src/segments/HICAZ.ts @@ -0,0 +1,31 @@ +import { Binary } from '../dataElements/Binary.js'; +import { Text } from '../dataElements/Text.js'; +import { InternationalAccount, InternationalAccountGroup } from '../dataGroups/InternationalAccount.js'; +import { DataGroup } from '../dataGroups/DataGroup.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; +import { Segment } from '../segment.js'; + +export type HICAZSegment = Segment & { + account: InternationalAccount; + camtDescriptor: string; + bookedTransactions: string[]; + notedTransactions?: string[]; +}; + +/** + * Account transactions within period response (CAMT format) + */ +export class HICAZ extends SegmentDefinition { + static Id = 'HICAZ'; + static Version = 1; + constructor() { + super(HICAZ.Id); + } + version = HICAZ.Version; + elements = [ + new InternationalAccountGroup('account', 1, 1), + new Text('camtDescriptor', 1, 1), // camt-Descriptor (single format used) + new DataGroup('bookedTransactions', [new Binary('camtMessage', 1, 99)], 1, 1), // Booked CAMT transactions + new DataGroup('notedTransactions', [new Binary('camtMessage', 1, 99)], 0, 1), // Noted CAMT transactions + ]; +} diff --git a/src/segments/HICAZS.ts b/src/segments/HICAZS.ts new file mode 100644 index 0000000..d039f05 --- /dev/null +++ b/src/segments/HICAZS.ts @@ -0,0 +1,30 @@ +import { YesNo } from '../dataElements/YesNo.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { BusinessTransactionParameter, BusinessTransactionParameterSegment } from './businessTransactionParameter.js'; +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; + +export type HICAZSSegment = BusinessTransactionParameterSegment; + +export type HICAZSParameter = { + maxDays: number; + entryCountAllowed: boolean; + allAccountsAllowed: boolean; + supportedCamtFormats: string[]; +}; + +/** + * Parameters for HKCAZ business transaction (CAMT format statement retrieval) + */ +export class HICAZS extends BusinessTransactionParameter { + static Id = 'HICAZS'; + version = 1; + + constructor() { + super(HICAZS.Id, [ + new Numeric('maxDays', 1, 1, 4), + new YesNo('maxEntryCountAllowed', 1, 1), + new YesNo('allAccountsAllowed', 1, 1), + new AlphaNumeric('supportedCamtFormats', 1, 99), + ]); + } +} diff --git a/src/segments/HKCAZ.ts b/src/segments/HKCAZ.ts new file mode 100644 index 0000000..3db0e41 --- /dev/null +++ b/src/segments/HKCAZ.ts @@ -0,0 +1,39 @@ +import { Dat } from '../dataElements/Dat.js'; +import { YesNo } from '../dataElements/YesNo.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { Text } from '../dataElements/Text.js'; +import { InternationalAccount, InternationalAccountGroup } from '../dataGroups/InternationalAccount.js'; +import { DataGroup } from '../dataGroups/DataGroup.js'; +import { SegmentWithContinuationMark } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; + +export type HKCAZSegment = SegmentWithContinuationMark & { + account: InternationalAccount; + acceptedCamtFormats: string[]; + allAccounts: boolean; + from?: Date; + to?: Date; + maxEntries?: number; +}; + +/** + * Request account transactions in a given period (CAMT format) + */ +export class HKCAZ extends SegmentDefinition { + static Id = 'HKCAZ'; + static Version = 1; + constructor() { + super(HKCAZ.Id); + } + version = HKCAZ.Version; + elements = [ + new InternationalAccountGroup('account', 1, 1), + new DataGroup('acceptedCamtFormats', [new Text('camtFormat', 1, 99)], 1, 1), // Support multiple camt-formats + new YesNo('allAccounts', 1, 1), + new Dat('from', 0, 1), + new Dat('to', 0, 1), + new Numeric('maxEntries', 0, 1, 4), + new AlphaNumeric('continuationMark', 0, 1, 35), + ]; +} diff --git a/src/segments/registry.ts b/src/segments/registry.ts index 047b08e..44b3dad 100644 --- a/src/segments/registry.ts +++ b/src/segments/registry.ts @@ -27,60 +27,64 @@ import { HISAL } from './HISAL.js'; import { HKKAZ } from './HKKAZ.js'; import { HIKAZ } from './HIKAZ.js'; import { HIKAZS } from './HIKAZS.js'; +import { HKCAZ } from './HKCAZ.js'; +import { HICAZ } from './HICAZ.js'; +import { HICAZS } from './HICAZS.js'; import { HITAB } from './HITAB.js'; import { HKWPD } from './HKWPD.js'; import { HIWPD } from './HIWPD.js'; -import { DIKKU } from "./DIKKU.js"; -import { DKKKU } from "./DKKKU.js"; +import { DIKKU } from './DIKKU.js'; +import { DKKKU } from './DKKKU.js'; import { UNKNOW } from '../unknownSegment.js'; import { PARTED } from '../partedSegment.js'; const registry = new Map(); export function registerSegments() { - registerSegmentDefinition(new HNHBK()); - registerSegmentDefinition(new HNHBS()); - registerSegmentDefinition(new HNVSK()); - registerSegmentDefinition(new HNVSD()); - registerSegmentDefinition(new HNSHK()); - registerSegmentDefinition(new HNSHA()); - registerSegmentDefinition(new HKIDN()); - registerSegmentDefinition(new HKVVB()); - registerSegmentDefinition(new HKSYN()); - registerSegmentDefinition(new HKTAN()); - registerSegmentDefinition(new HKTAB()); - registerSegmentDefinition(new HIRMG()); - registerSegmentDefinition(new HIRMS()); - registerSegmentDefinition(new HIBPA()); - registerSegmentDefinition(new HIKOM()); - registerSegmentDefinition(new HIKIM()); - registerSegmentDefinition(new HISYN()); - registerSegmentDefinition(new HITAB()); - registerSegmentDefinition(new HIPINS()); - registerSegmentDefinition(new HITAN()); - registerSegmentDefinition(new HITANS()); - registerSegmentDefinition(new HIUPA()); - registerSegmentDefinition(new HIUPD()); - registerSegmentDefinition(new HKEND()); - registerSegmentDefinition(new HKSAL()); - registerSegmentDefinition(new HISAL()); - registerSegmentDefinition(new HKKAZ()); - registerSegmentDefinition(new DKKKU()); - registerSegmentDefinition(new DIKKU()); - registerSegmentDefinition(new HIKAZ()); - registerSegmentDefinition(new HIKAZS()); - registerSegmentDefinition(new HKWPD()); - registerSegmentDefinition(new HIWPD()); - registerSegmentDefinition(new UNKNOW()); - registerSegmentDefinition(new PARTED()); + registerSegmentDefinition(new HNHBK()); + registerSegmentDefinition(new HNHBS()); + registerSegmentDefinition(new HNVSK()); + registerSegmentDefinition(new HNVSD()); + registerSegmentDefinition(new HNSHK()); + registerSegmentDefinition(new HNSHA()); + registerSegmentDefinition(new HKIDN()); + registerSegmentDefinition(new HKVVB()); + registerSegmentDefinition(new HKSYN()); + registerSegmentDefinition(new HKTAN()); + registerSegmentDefinition(new HKTAB()); + registerSegmentDefinition(new HIRMG()); + registerSegmentDefinition(new HIRMS()); + registerSegmentDefinition(new HIBPA()); + registerSegmentDefinition(new HIKOM()); + registerSegmentDefinition(new HIKIM()); + registerSegmentDefinition(new HISYN()); + registerSegmentDefinition(new HITAB()); + registerSegmentDefinition(new HIPINS()); + registerSegmentDefinition(new HITAN()); + registerSegmentDefinition(new HITANS()); + registerSegmentDefinition(new HIUPA()); + registerSegmentDefinition(new HIUPD()); + registerSegmentDefinition(new HKEND()); + registerSegmentDefinition(new HKSAL()); + registerSegmentDefinition(new HISAL()); + registerSegmentDefinition(new HKKAZ()); + registerSegmentDefinition(new DKKKU()); + registerSegmentDefinition(new DIKKU()); + registerSegmentDefinition(new HIKAZ()); + registerSegmentDefinition(new HIKAZS()); + registerSegmentDefinition(new HKCAZ()); + registerSegmentDefinition(new HICAZ()); + registerSegmentDefinition(new HICAZS()); + registerSegmentDefinition(new HKWPD()); + registerSegmentDefinition(new HIWPD()); + registerSegmentDefinition(new UNKNOW()); + registerSegmentDefinition(new PARTED()); } -export function getSegmentDefinition( - id: string -): SegmentDefinition | undefined { - return registry.get(id); +export function getSegmentDefinition(id: string): SegmentDefinition | undefined { + return registry.get(id); } function registerSegmentDefinition(definition: SegmentDefinition) { - registry.set(definition.id, definition); + registry.set(definition.id, definition); } diff --git a/src/statement.ts b/src/statement.ts new file mode 100644 index 0000000..5992063 --- /dev/null +++ b/src/statement.ts @@ -0,0 +1,40 @@ +export interface Statement { + transactionReference?: string; + relatedReference?: string; + account?: string; + number?: string; + openingBalance: Balance; + transactions: Transaction[]; + closingBalance: Balance; + availableBalance?: Balance; + forwardBalances?: Balance[]; +} + +export interface Transaction { + valueDate: Date; + entryDate: Date; + fundsCode: string; + amount: number; + transactionType: string; + customerReference: string; + bankReference: string; + transactionCode?: string; + bookingText?: string; + primeNotesNr?: string; + purpose?: string; + remoteBankId?: string; + remoteAccountNumber?: string; + remoteName?: string; + remoteIdentifier?: string; + client?: string; + e2eReference?: string; + mandateReference?: string; + textKeyExtension?: string; + additionalInformation?: string; +} + +export interface Balance { + date: Date; + currency: string; + value: number; +} diff --git a/src/tests/HICAZS.test.ts b/src/tests/HICAZS.test.ts new file mode 100644 index 0000000..5285c9c --- /dev/null +++ b/src/tests/HICAZS.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { registerSegments } from '../segments/registry.js'; +import { decode, encode } from '../segment.js'; +import { HICAZSSegment } from '../segments/HICAZS.js'; + +registerSegments(); + +describe('HICAZS', () => { + it('decode and encode roundtrip matches', () => { + const text = + "HICAZS:16:1:4+1+1+0+450:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02'"; + const segment = decode(text) as HICAZSSegment; + + expect(segment.params.maxDays).toBe(450); + expect(segment.params.entryCountAllowed).toBeFalsy(); + expect(segment.params.allAccountsAllowed).toBeFalsy(); + expect(segment.params.supportedCamtFormats).toEqual([ + 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.08', + 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.02', + ]); + + expect(encode(segment)).toBe(text); + }); +}); diff --git a/src/tests/HKCAZ.test.ts b/src/tests/HKCAZ.test.ts new file mode 100644 index 0000000..1cfd5ea --- /dev/null +++ b/src/tests/HKCAZ.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { registerSegments } from '../segments/registry.js'; +import { HKCAZ, HKCAZSegment } from '../segments/HKCAZ.js'; +import { decode, encode } from '../segment.js'; + +registerSegments(); + +describe('HKCAZ v1', () => { + it('encode', () => { + const segment: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 1, version: 1 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + accountNumber: '123456', + bank: { country: 280, bankId: '12030000' }, + }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02'], + allAccounts: false, + from: new Date('2023-01-01'), + to: new Date('2023-12-31'), + }; + + expect(encode(segment)).toBe( + "HKCAZ:1:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20230101+20231231'" + ); + }); + + it('encode without optional dates', () => { + const segment: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 2, version: 1 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + accountNumber: '123456', + bank: { country: 280, bankId: '12030000' }, + }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02'], + allAccounts: true, + }; + + expect(encode(segment)).toBe( + "HKCAZ:2:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+J'" + ); + }); + + it('decode and encode roundtrip matches', () => { + const text = + "HKCAZ:0:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20230101+20231231'"; + const segment = decode(text); + expect(encode(segment)).toBe(text); + }); + + it('decode and encode roundtrip without dates', () => { + const text = "HKCAZ:0:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+J'"; + const segment = decode(text); + expect(encode(segment)).toBe(text); + }); +}); diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts new file mode 100644 index 0000000..5b87ecd --- /dev/null +++ b/src/tests/camtParser.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from 'vitest'; +import { CamtParser } from '../camtParser.js'; + +describe('CamtParser', () => { + it('should parse CAMT.052 XML with balances and transactions', () => { + const camtXml = ` + + + + camt52_20131118101510__ONLINEBA + 2013-11-18T10:15:10+01:00 + + + camt052_ONLINEBA + 00001 + 2013-11-18T10:15:10+01:00 + + + DE06940594210000027227 + + EUR + + + + + PRCD + + + 1000.00 + CRDT +
+
2013-10-31
+ +
+ + + + CLBD + + + 1500.00 + CRDT +
+
2013-11-04
+ +
+ + 500.00 + CRDT + +
2013-11-01
+
+ +
2013-11-01
+
+ TXN001 + + + TRF + + + + + + E2E123 + MANDT001 + + + Test payment + + + + John Doe + + + + DE12345678901234567890 + + + + Jane Doe + + + + DE12345678901234567891 + + + + + + + BYLADEM1001 + + + + + DEUTDEFF + + + + + +
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + + const statement = statements[0]; + expect(statement.account).toBe('DE06940594210000027227'); + expect(statement.number).toBe('camt052_ONLINEBA'); + expect(statement.transactionReference).toBe('00001'); + + // Check balances + expect(statement.openingBalance).toBeDefined(); + expect(statement.openingBalance.value).toBe(1000.0); + expect(statement.openingBalance.currency).toBe('EUR'); + + expect(statement.closingBalance).toBeDefined(); + expect(statement.closingBalance.value).toBe(1500.0); + expect(statement.closingBalance.currency).toBe('EUR'); + + // Check transactions + expect(statement.transactions).toHaveLength(1); + + const transaction = statement.transactions[0]; + expect(transaction.amount).toBe(500.0); + expect(transaction.customerReference).toBe('E2E123'); + expect(transaction.bankReference).toBe('TXN001'); + expect(transaction.purpose).toBe('Test payment'); + expect(transaction.remoteName).toBe('John Doe'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); + expect(transaction.remoteBankId).toBe('BYLADEM1001'); // Credit transaction uses DbtrAgt BIC + expect(transaction.e2eReference).toBe('E2E123'); + expect(transaction.mandateReference).toBe('MANDT001'); + }); + + it('should handle debit transactions correctly', () => { + const camtXml = ` + + + + test + + + DE06940594210000027227 + + + + PRCD + 1000.00 + CRDT +
2013-10-31
+
+ + CLBD + 800.00 + CRDT +
2013-11-01
+
+ + 200.00 + DBIT +
2013-11-01
+
2013-11-01
+ TXN002 + + + + 485315597247918 + + + + 5.83 + + + + + Jane Doe + + + + DE12345678901234567891 + + + + John Doe + + + + DE12345678901234567890 + + + + + + + SOGEDEFF + + + + + DEUTDEFF + + + + + IDCP + + + Test payment + + + + Additional Info +
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + expect(statements[0].transactions).toHaveLength(1); + expect(statements[0].transactions[0].amount).toBe(-200.0); // Should be negative for debit + + const transaction = statements[0].transactions[0]; + expect(transaction.purpose).toBe('Test payment'); + expect(transaction.remoteName).toBe('John Doe'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); + expect(transaction.remoteBankId).toBe('DEUTDEFF'); // Debit transaction uses CdtrAgt BIC + }); + + it('should handle empty or invalid XML gracefully', () => { + const parser = new CamtParser('invalid xml'); + const statements = parser.parse(); + expect(statements).toHaveLength(0); // Should return empty array instead of throwing + }); + + it('should handle multiple reports', () => { + const camtXml = ` + + + + report1 + DE11111111111111111111 + PRCD100.00CRDT
2023-01-01
+ CLBD200.00CRDT
2023-01-01
+
+ + report2 + DE22222222222222222222 + PRCD300.00CRDT
2023-01-01
+ CLBD400.00CRDT
2023-01-01
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(2); + expect(statements[0].account).toBe('DE11111111111111111111'); + expect(statements[1].account).toBe('DE22222222222222222222'); + }); +}); From ae917d833aabcaa2bc4b0b6ab78d6f032880d545 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Mon, 22 Dec 2025 13:19:28 +0100 Subject: [PATCH 3/6] Refactor CamtParser for improved parsing logic and add comprehensive tests for CAMT XML handling --- src/camtParser.ts | 540 +++++++++---------- src/interactions/balanceInteraction.ts | 4 +- src/interactions/statementInteractionCAMT.ts | 2 +- src/tests/camtParser.test.ts | 491 ++++++++++++++--- 4 files changed, 702 insertions(+), 335 deletions(-) diff --git a/src/camtParser.ts b/src/camtParser.ts index e34fbc2..5ce066d 100644 --- a/src/camtParser.ts +++ b/src/camtParser.ts @@ -1,274 +1,274 @@ import { Statement, Transaction, Balance } from './statement.js'; export class CamtParser { - private xmlData: string; - - constructor(xmlData: string) { - this.xmlData = xmlData; - } - - parse(): Statement[] { - try { - const statements: Statement[] = []; - - // Parse multiple reports using regex (CAMT.053 can contain multiple reports) - const reportMatches = this.xmlData.match(/[\s\S]*?<\/Rpt>/g); - if (!reportMatches) { - return statements; - } - - for (const reportXml of reportMatches) { - const statement = this.parseReport(reportXml); - if (statement) { - statements.push(statement); - } - } - - return statements; - } catch (error) { - throw new Error(`Failed to parse CAMT data: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private parseReport(reportXml: string): Statement | null { - // Extract account information - const account = this.extractTagValue(reportXml, 'IBAN'); - - // Extract statement number/ID - const number = this.extractTagValue(reportXml, 'Id'); - - // Extract transaction reference - const transactionReference = this.extractTagValue(reportXml, 'ElctrncSeqNb'); - - // Parse balances - const balances = this.parseBalances(reportXml); - if (!balances.openingBalance || !balances.closingBalance) { - return null; // Need at least opening and closing balance - } - - // Parse transactions - const transactions = this.parseTransactions(reportXml); - - return { - account, - number, - transactionReference, - openingBalance: balances.openingBalance, - closingBalance: balances.closingBalance, - availableBalance: balances.availableBalance, - transactions, - }; - } - - private parseBalances(reportXml: string): { - openingBalance?: Balance; - closingBalance?: Balance; - availableBalance?: Balance; - } { - let openingBalance: Balance | undefined; - let closingBalance: Balance | undefined; - let availableBalance: Balance | undefined; - - // Extract all balance elements - const balanceMatches = reportXml.match(//g); - if (!balanceMatches) { - return { openingBalance, closingBalance, availableBalance }; - } - - for (const balanceXml of balanceMatches) { - const typeCode = this.extractTagValue(balanceXml, 'Cd'); - - // Extract amount and currency - const amtMatch = balanceXml.match(/([^<]+)<\/Amt>/); - const currency = amtMatch ? amtMatch[1] : 'EUR'; - const value = amtMatch ? parseFloat(amtMatch[2]) : 0; - - const creditDebitInd = this.extractTagValue(balanceXml, 'CdtDbtInd'); - const finalValue = creditDebitInd === 'DBIT' ? -value : value; - - const dateStr = this.extractTagValue(balanceXml, 'Dt'); - const date = dateStr ? this.parseDate(dateStr) : new Date(); - - const balance: Balance = { - date, - currency, - value: finalValue, - }; - - switch (typeCode) { - case 'PRCD': // Previous closing date - openingBalance = balance; - break; - case 'CLBD': // Closing booked - closingBalance = balance; - break; - case 'ITBD': // Interim booked - case 'FWAV': // Forward available - availableBalance = balance; - break; - } - } - - return { openingBalance, closingBalance, availableBalance }; - } - - private parseTransactions(reportXml: string): Transaction[] { - const transactions: Transaction[] = []; - const entryMatches = reportXml.match(//g); - - if (!entryMatches) { - return transactions; - } - - for (const entryXml of entryMatches) { - const transaction = this.parseTransaction(entryXml); - if (transaction) { - transactions.push(transaction); - } - } - - return transactions; - } - - private parseTransaction(entryXml: string): Transaction | null { - try { - // Extract amount and credit/debit indicator - const amtMatch = entryXml.match(/]*>([^<]+)<\/Amt>/); - const amountValue = amtMatch ? parseFloat(amtMatch[1]) : 0; - const creditDebitInd = this.extractTagValue(entryXml, 'CdtDbtInd'); - const isDebit = creditDebitInd === 'DBIT'; - const amount = isDebit ? -amountValue : amountValue; - - // Extract dates - const bookingDate = this.extractNestedTagValue(entryXml, 'BookgDt', 'Dt'); - const valueDate = this.extractNestedTagValue(entryXml, 'ValDt', 'Dt'); - - const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date(); - const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate; - - // Extract references - const accountServicerRef = this.extractTagValue(entryXml, 'AcctSvcrRef') || ''; - const endToEndId = this.extractTagValue(entryXml, 'EndToEndId') || ''; - const mandateId = this.extractTagValue(entryXml, 'MndtId') || ''; - - // Extract transaction details - const additionalEntryInfo = this.extractTagValue(entryXml, 'AddtlNtryInf') || ''; - const remittanceInfo = this.extractTagValue(entryXml, 'Ustrd') || ''; - - // Extract remote party information based on transaction type - let remoteName = ''; - let remoteIBAN = ''; - let remoteBankId = ''; - - if (isDebit) { - // For debit transactions, we want the creditor (receiving party) - const creditorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Cdtr>/); - remoteName = creditorNameMatch ? creditorNameMatch[1] : ''; - - const creditorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/CdtrAcct>/); - remoteIBAN = creditorIbanMatch ? creditorIbanMatch[1] : ''; - - // For debit, get creditor's bank BIC - const creditorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/CdtrAgt>/); - remoteBankId = creditorBicMatch ? creditorBicMatch[1] : ''; - } else { - // For credit transactions, we want the debtor (sending party) - const debtorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Dbtr>/); - remoteName = debtorNameMatch ? debtorNameMatch[1] : ''; - - const debtorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/DbtrAcct>/); - remoteIBAN = debtorIbanMatch ? debtorIbanMatch[1] : ''; - - // For credit, get debtor's bank BIC - const debtorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/DbtrAgt>/); - remoteBankId = debtorBicMatch ? debtorBicMatch[1] : ''; - } - - // Extract bank transaction code structure (BkTxCd) - const bkTxCd = this.parseBankTransactionCode(entryXml); - - return { - valueDate: parsedValueDate, - entryDate, - fundsCode: bkTxCd.domainCode || creditDebitInd || '', - amount, - transactionType: bkTxCd.familyCode || '', - customerReference: endToEndId, - bankReference: accountServicerRef, - transactionCode: bkTxCd.subFamilyCode || '', - purpose: remittanceInfo, - remoteName, - remoteAccountNumber: remoteIBAN, - remoteBankId, - e2eReference: endToEndId, - mandateReference: mandateId, - additionalInformation: additionalEntryInfo, - bookingText: additionalEntryInfo, - }; - } catch (error) { - console.warn('Failed to parse CAMT transaction entry:', error); - return null; - } - } - - private parseDate(dateStr: string): Date { - // Parse ISO date format (YYYY-MM-DD) - if (dateStr.length === 10 && dateStr.includes('-')) { - return new Date(dateStr + 'T12:00:00'); // Set time to noon to avoid timezone issues - } - - // Parse CAMT date format (YYYYMMDD) - if (dateStr.length === 8) { - const year = parseInt(dateStr.substring(0, 4), 10); - const month = parseInt(dateStr.substring(4, 6), 10) - 1; // Month is 0-based - const day = parseInt(dateStr.substring(6, 8), 10); - return new Date(year, month, day, 12); - } - - return new Date(dateStr); - } - - private extractTagValue(xml: string, tagName: string): string | undefined { - const pattern = new RegExp(`<${tagName}>([^<]*)<\\/${tagName}>`, 'i'); - const match = xml.match(pattern); - return match ? match[1] : undefined; - } - - private extractNestedTagValue(xml: string, parentTag: string, childTag: string): string | undefined { - const parentPattern = new RegExp(`<${parentTag}[\\s\\S]*?<\\/${parentTag}>`, 'i'); - const parentMatch = xml.match(parentPattern); - if (!parentMatch) { - return undefined; - } - return this.extractTagValue(parentMatch[0], childTag); - } - - private parseBankTransactionCode(entryXml: string): { - domainCode?: string; - familyCode?: string; - subFamilyCode?: string; - } { - // Extract the entire BkTxCd block - const bkTxCdMatch = entryXml.match(/[\s\S]*?<\/BkTxCd>/); - if (!bkTxCdMatch) { - return {}; - } - - const bkTxCdXml = bkTxCdMatch[0]; - - // Extract Domain Code (first level - e.g., "PMNT") - const domainCode = this.extractNestedTagValue(bkTxCdXml, 'Domn', 'Cd'); - - // Extract Family Code (second level - e.g., "CCRD") - const familyCode = this.extractNestedTagValue(bkTxCdXml, 'Fmly', 'Cd'); - - // Extract SubFamily Code (third level - e.g., "POSD") - const subFamilyCode = this.extractTagValue(bkTxCdXml, 'SubFmlyCd'); - - return { - domainCode, - familyCode, - subFamilyCode, - }; - } + private xmlData: string; + + constructor(xmlData: string) { + this.xmlData = xmlData; + } + + parse(): Statement[] { + try { + const statements: Statement[] = []; + + // Parse multiple reports using regex (CAMT.053 can contain multiple reports) + const reportMatches = this.xmlData.match(/[\s\S]*?<\/Rpt>/g); + if (!reportMatches) { + return statements; + } + + for (const reportXml of reportMatches) { + const statement = this.parseReport(reportXml); + if (statement) { + statements.push(statement); + } + } + + return statements; + } catch (error) { + throw new Error(`Failed to parse CAMT data: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + private parseReport(reportXml: string): Statement | null { + // Extract account information + const account = this.extractTagValue(reportXml, 'IBAN'); + + // Extract statement number/ID + const number = this.extractTagValue(reportXml, 'Id'); + + // Extract transaction reference + const transactionReference = this.extractTagValue(reportXml, 'ElctrncSeqNb'); + + // Parse balances + const balances = this.parseBalances(reportXml); + if (!balances.openingBalance || !balances.closingBalance) { + return null; // Need at least opening and closing balance + } + + // Parse transactions + const transactions = this.parseTransactions(reportXml); + + return { + account, + number, + transactionReference, + openingBalance: balances.openingBalance, + closingBalance: balances.closingBalance, + availableBalance: balances.availableBalance, + transactions, + }; + } + + private parseBalances(reportXml: string): { + openingBalance?: Balance; + closingBalance?: Balance; + availableBalance?: Balance; + } { + let openingBalance: Balance | undefined; + let closingBalance: Balance | undefined; + let availableBalance: Balance | undefined; + + // Extract all balance elements + const balanceMatches = reportXml.match(//g); + if (!balanceMatches) { + return { openingBalance, closingBalance, availableBalance }; + } + + for (const balanceXml of balanceMatches) { + const typeCode = this.extractTagValue(balanceXml, 'Cd'); + + // Extract amount and currency + const amtMatch = balanceXml.match(/([^<]+)<\/Amt>/); + const currency = amtMatch ? amtMatch[1] : 'EUR'; + const value = amtMatch ? parseFloat(amtMatch[2]) : 0; + + const creditDebitInd = this.extractTagValue(balanceXml, 'CdtDbtInd'); + const finalValue = creditDebitInd === 'DBIT' ? -value : value; + + const dateStr = this.extractTagValue(balanceXml, 'Dt'); + const date = dateStr ? this.parseDate(dateStr) : new Date(); + + const balance: Balance = { + date, + currency, + value: finalValue, + }; + + switch (typeCode) { + case 'PRCD': // Previous closing date + openingBalance = balance; + break; + case 'CLBD': // Closing booked + closingBalance = balance; + break; + case 'ITBD': // Interim booked + case 'FWAV': // Forward available + availableBalance = balance; + break; + } + } + + return { openingBalance, closingBalance, availableBalance }; + } + + private parseTransactions(reportXml: string): Transaction[] { + const transactions: Transaction[] = []; + const entryMatches = reportXml.match(//g); + + if (!entryMatches) { + return transactions; + } + + for (const entryXml of entryMatches) { + const transaction = this.parseTransaction(entryXml); + if (transaction) { + transactions.push(transaction); + } + } + + return transactions; + } + + private parseTransaction(entryXml: string): Transaction | null { + try { + // Extract amount and credit/debit indicator + const amtMatch = entryXml.match(/]*>([^<]+)<\/Amt>/); + const amountValue = amtMatch ? parseFloat(amtMatch[1]) : 0; + const creditDebitInd = this.extractTagValue(entryXml, 'CdtDbtInd'); + const isDebit = creditDebitInd === 'DBIT'; + const amount = isDebit ? -amountValue : amountValue; + + // Extract dates + const bookingDate = this.extractNestedTagValue(entryXml, 'BookgDt', 'Dt'); + const valueDate = this.extractNestedTagValue(entryXml, 'ValDt', 'Dt'); + + const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date(); + const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate; + + // Extract references + const accountServicerRef = this.extractTagValue(entryXml, 'AcctSvcrRef') || ''; + const endToEndId = this.extractTagValue(entryXml, 'EndToEndId') || ''; + const mandateId = this.extractTagValue(entryXml, 'MndtId') || ''; + + // Extract transaction details + const additionalEntryInfo = this.extractTagValue(entryXml, 'AddtlNtryInf') || ''; + const remittanceInfo = this.extractTagValue(entryXml, 'Ustrd') || ''; + + // Extract remote party information based on transaction type + let remoteName = ''; + let remoteIBAN = ''; + let remoteBankId = ''; + + if (isDebit) { + // For debit transactions, we want the creditor (receiving party) + const creditorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Cdtr>/); + remoteName = creditorNameMatch ? creditorNameMatch[1] : ''; + + const creditorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/CdtrAcct>/); + remoteIBAN = creditorIbanMatch ? creditorIbanMatch[1] : ''; + + // For debit, get creditor's bank BIC + const creditorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/CdtrAgt>/); + remoteBankId = creditorBicMatch ? creditorBicMatch[1] : ''; + } else { + // For credit transactions, we want the debtor (sending party) + const debtorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Dbtr>/); + remoteName = debtorNameMatch ? debtorNameMatch[1] : ''; + + const debtorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/DbtrAcct>/); + remoteIBAN = debtorIbanMatch ? debtorIbanMatch[1] : ''; + + // For credit, get debtor's bank BIC + const debtorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/DbtrAgt>/); + remoteBankId = debtorBicMatch ? debtorBicMatch[1] : ''; + } + + // Extract bank transaction code structure (BkTxCd) + const bkTxCd = this.parseBankTransactionCode(entryXml); + + return { + valueDate: parsedValueDate, + entryDate, + fundsCode: bkTxCd.domainCode || creditDebitInd || '', + amount, + transactionType: bkTxCd.familyCode || '', + customerReference: endToEndId, + bankReference: accountServicerRef, + transactionCode: bkTxCd.subFamilyCode || '', + purpose: remittanceInfo, + remoteName, + remoteAccountNumber: remoteIBAN, + remoteBankId, + e2eReference: endToEndId, + mandateReference: mandateId, + additionalInformation: additionalEntryInfo, + bookingText: additionalEntryInfo, + }; + } catch (error) { + console.warn('Failed to parse CAMT transaction entry:', error); + return null; + } + } + + private parseDate(dateStr: string): Date { + // Parse ISO date format (YYYY-MM-DD) + if (dateStr.length === 10 && dateStr.includes('-')) { + return new Date(dateStr + 'T12:00:00'); // Set time to noon to avoid timezone issues + } + + // Parse CAMT date format (YYYYMMDD) + if (dateStr.length === 8) { + const year = parseInt(dateStr.substring(0, 4), 10); + const month = parseInt(dateStr.substring(4, 6), 10) - 1; // Month is 0-based + const day = parseInt(dateStr.substring(6, 8), 10); + return new Date(year, month, day, 12); + } + + return new Date(dateStr); + } + + private extractTagValue(xml: string, tagName: string): string | undefined { + const pattern = new RegExp(`<${tagName}>([^<]*)<\\/${tagName}>`, 'i'); + const match = xml.match(pattern); + return match ? match[1] : undefined; + } + + private extractNestedTagValue(xml: string, parentTag: string, childTag: string): string | undefined { + const parentPattern = new RegExp(`<${parentTag}[\\s\\S]*?<\\/${parentTag}>`, 'i'); + const parentMatch = xml.match(parentPattern); + if (!parentMatch) { + return undefined; + } + return this.extractTagValue(parentMatch[0], childTag); + } + + private parseBankTransactionCode(entryXml: string): { + domainCode?: string; + familyCode?: string; + subFamilyCode?: string; + } { + // Extract the entire BkTxCd block + const bkTxCdMatch = entryXml.match(/[\s\S]*?<\/BkTxCd>/); + if (!bkTxCdMatch) { + return {}; + } + + const bkTxCdXml = bkTxCdMatch[0]; + + // Extract Domain Code (first level - e.g., "PMNT") + const domainCode = this.extractNestedTagValue(bkTxCdXml, 'Domn', 'Cd'); + + // Extract Family Code (second level - e.g., "CCRD") + const familyCode = this.extractNestedTagValue(bkTxCdXml, 'Fmly', 'Cd'); + + // Extract SubFamily Code (third level - e.g., "POSD") + const subFamilyCode = this.extractTagValue(bkTxCdXml, 'SubFmlyCd'); + + return { + domainCode, + familyCode, + subFamilyCode, + }; + } } diff --git a/src/interactions/balanceInteraction.ts b/src/interactions/balanceInteraction.ts index 592c6dc..acc8883 100644 --- a/src/interactions/balanceInteraction.ts +++ b/src/interactions/balanceInteraction.ts @@ -23,14 +23,14 @@ export class BalanceInteraction extends CustomerOrderInteraction { throw Error(`Account ${this.accountNumber} does not support business transaction '${this.segId}'`); } - const account = { ...bankAccount, iban: undefined }; - const version = init.getMaxSupportedTransactionVersion(HKSAL.Id); if (!version) { throw Error(`There is no supported version for business transaction '${HKSAL.Id}`); } + const account = version <= 6 ? { ...bankAccount, iban: undefined, bic: undefined } : bankAccount; + const hksal: HKSALSegment = { header: { segId: HKSAL.Id, segNr: 0, version: version }, account, diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index ed35161..9767fff 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -8,7 +8,7 @@ import { Segment } from '../segment.js'; import { FinTSConfig } from '../config.js'; export class StatementInteractionCAMT extends CustomerOrderInteraction { - private acceptedCamtFormats: string[] = ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02']; + private acceptedCamtFormats: string[] = ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08']; constructor(public accountNumber: string, public from?: Date, public to?: Date) { super(HKCAZ.Id, HICAZ.Id); diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts index 5b87ecd..72eee3f 100644 --- a/src/tests/camtParser.test.ts +++ b/src/tests/camtParser.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { CamtParser } from '../camtParser.js'; describe('CamtParser', () => { - it('should parse CAMT.052 XML with balances and transactions', () => { - const camtXml = ` + it('should parse CAMT.052 XML with balances and transactions', () => { + const camtXml = ` @@ -105,42 +105,84 @@ describe('CamtParser', () => { `; - const parser = new CamtParser(camtXml); - const statements = parser.parse(); - - expect(statements).toHaveLength(1); - - const statement = statements[0]; - expect(statement.account).toBe('DE06940594210000027227'); - expect(statement.number).toBe('camt052_ONLINEBA'); - expect(statement.transactionReference).toBe('00001'); - - // Check balances - expect(statement.openingBalance).toBeDefined(); - expect(statement.openingBalance.value).toBe(1000.0); - expect(statement.openingBalance.currency).toBe('EUR'); - - expect(statement.closingBalance).toBeDefined(); - expect(statement.closingBalance.value).toBe(1500.0); - expect(statement.closingBalance.currency).toBe('EUR'); - - // Check transactions - expect(statement.transactions).toHaveLength(1); - - const transaction = statement.transactions[0]; - expect(transaction.amount).toBe(500.0); - expect(transaction.customerReference).toBe('E2E123'); - expect(transaction.bankReference).toBe('TXN001'); - expect(transaction.purpose).toBe('Test payment'); - expect(transaction.remoteName).toBe('John Doe'); - expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); - expect(transaction.remoteBankId).toBe('BYLADEM1001'); // Credit transaction uses DbtrAgt BIC - expect(transaction.e2eReference).toBe('E2E123'); - expect(transaction.mandateReference).toBe('MANDT001'); - }); - - it('should handle debit transactions correctly', () => { - const camtXml = ` + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + + const statement = statements[0]; + + // Check all Statement fields + expect(statement.account).toBe('DE06940594210000027227'); + expect(statement.number).toBe('camt052_ONLINEBA'); + expect(statement.transactionReference).toBe('00001'); + + // Verify optional fields that are not set + expect(statement.relatedReference).toBeUndefined(); + expect(statement.availableBalance).toBeUndefined(); + expect(statement.forwardBalances).toBeUndefined(); + + // Check balances with all fields + expect(statement.openingBalance).toBeDefined(); + expect(statement.openingBalance.value).toBe(1000.0); + expect(statement.openingBalance.currency).toBe('EUR'); + expect(statement.openingBalance.date).toBeInstanceOf(Date); + expect(statement.openingBalance.date.getFullYear()).toBe(2013); + expect(statement.openingBalance.date.getMonth()).toBe(9); // October (0-based) + expect(statement.openingBalance.date.getDate()).toBe(31); + + expect(statement.closingBalance).toBeDefined(); + expect(statement.closingBalance.value).toBe(1500.0); + expect(statement.closingBalance.currency).toBe('EUR'); + expect(statement.closingBalance.date).toBeInstanceOf(Date); + expect(statement.closingBalance.date.getFullYear()).toBe(2013); + expect(statement.closingBalance.date.getMonth()).toBe(10); // November (0-based) + expect(statement.closingBalance.date.getDate()).toBe(4); + + // Check transactions + expect(statement.transactions).toHaveLength(1); + + const transaction = statement.transactions[0]; + + // Check all Transaction fields filled by the parser + expect(transaction.amount).toBe(500.0); + expect(transaction.customerReference).toBe('E2E123'); + expect(transaction.bankReference).toBe('TXN001'); + expect(transaction.purpose).toBe('Test payment'); + expect(transaction.remoteName).toBe('John Doe'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); + expect(transaction.remoteBankId).toBe('BYLADEM1001'); // Credit transaction uses DbtrAgt BIC + expect(transaction.e2eReference).toBe('E2E123'); + expect(transaction.mandateReference).toBe('MANDT001'); + + // Check date fields + expect(transaction.valueDate).toBeInstanceOf(Date); + expect(transaction.valueDate.getFullYear()).toBe(2013); + expect(transaction.valueDate.getMonth()).toBe(10); // November (0-based) + expect(transaction.valueDate.getDate()).toBe(1); + expect(transaction.entryDate).toBeInstanceOf(Date); + expect(transaction.entryDate.getFullYear()).toBe(2013); + expect(transaction.entryDate.getMonth()).toBe(10); // November (0-based) + expect(transaction.entryDate.getDate()).toBe(1); + + // Check transaction type and code fields + expect(transaction.fundsCode).toBe('CRDT'); // Credit/debit indicator + expect(transaction.transactionType).toBe(''); // Should be empty as no family code in BkTxCd + expect(transaction.transactionCode).toBe(''); // Parser only handles structured BkTxCd, not Prtry format + + // Check additional information fields + expect(transaction.additionalInformation).toBe(''); // No AddtlNtryInf in this test + expect(transaction.bookingText).toBe(''); // Should match additionalInformation + + // Verify optional fields not set in this test + expect(transaction.primeNotesNr).toBeUndefined(); + expect(transaction.remoteIdentifier).toBeUndefined(); + expect(transaction.client).toBeUndefined(); + expect(transaction.textKeyExtension).toBeUndefined(); + }); + + it('should handle debit transactions correctly', () => { + const camtXml = ` @@ -222,28 +264,51 @@ describe('CamtParser', () => { `; - const parser = new CamtParser(camtXml); - const statements = parser.parse(); + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + expect(statements[0].transactions).toHaveLength(1); + expect(statements[0].transactions[0].amount).toBe(-200.0); // Should be negative for debit + + const transaction = statements[0].transactions[0]; + + // Comprehensive Transaction field checks for debit transaction + expect(transaction.purpose).toBe('Test payment'); + expect(transaction.remoteName).toBe('John Doe'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); + expect(transaction.remoteBankId).toBe('DEUTDEFF'); // Debit transaction uses CdtrAgt BIC + + // Check all other fields for debit transaction + expect(transaction.customerReference).toBe('485315597247918'); + expect(transaction.bankReference).toBe('TXN002'); + expect(transaction.e2eReference).toBe('485315597247918'); + expect(transaction.mandateReference).toBe(''); // No MndtId in this test + + // Date fields + expect(transaction.valueDate).toBeInstanceOf(Date); + expect(transaction.valueDate.getFullYear()).toBe(2013); + expect(transaction.entryDate).toBeInstanceOf(Date); + expect(transaction.entryDate.getFullYear()).toBe(2013); - expect(statements).toHaveLength(1); - expect(statements[0].transactions).toHaveLength(1); - expect(statements[0].transactions[0].amount).toBe(-200.0); // Should be negative for debit + // Transaction type indicators + expect(transaction.fundsCode).toBe('DBIT'); // Debit indicator + expect(transaction.transactionType).toBe(''); // No family code + expect(transaction.transactionCode).toBe(''); // No BkTxCd in this test - const transaction = statements[0].transactions[0]; - expect(transaction.purpose).toBe('Test payment'); - expect(transaction.remoteName).toBe('John Doe'); - expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); - expect(transaction.remoteBankId).toBe('DEUTDEFF'); // Debit transaction uses CdtrAgt BIC - }); + // Additional info fields + expect(transaction.additionalInformation).toBe('Additional Info'); + expect(transaction.bookingText).toBe('Additional Info'); + }); - it('should handle empty or invalid XML gracefully', () => { - const parser = new CamtParser('invalid xml'); - const statements = parser.parse(); - expect(statements).toHaveLength(0); // Should return empty array instead of throwing - }); + it('should handle empty or invalid XML gracefully', () => { + const parser = new CamtParser('invalid xml'); + const statements = parser.parse(); + expect(statements).toHaveLength(0); // Should return empty array instead of throwing + }); - it('should handle multiple reports', () => { - const camtXml = ` + it('should handle multiple reports', () => { + const camtXml = ` @@ -261,11 +326,313 @@ describe('CamtParser', () => { `; - const parser = new CamtParser(camtXml); - const statements = parser.parse(); + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(2); + expect(statements[0].account).toBe('DE11111111111111111111'); + expect(statements[1].account).toBe('DE22222222222222222222'); + }); + + it('should parse comprehensive CAMT XML with all possible fields and bank transaction codes', () => { + const camtXml = ` + + + + comprehensive_test + 2023-12-22T14:30:00+01:00 + + + COMPREHENSIVE_TEST + 987654321 + 2023-12-22T14:30:00+01:00 + + + DE89370400440532013000 + + EUR + + + + + PRCD + + + 5000.50 + CRDT +
+
2023-12-21
+ +
+ + + + CLBD + + + 4750.75 + CRDT +
+
2023-12-22
+ +
+ + + + ITBD + + + 4500.00 + CRDT +
+
2023-12-22
+ +
+ + 249.75 + DBIT + +
2023-12-22
+
+ +
2023-12-22
+
+ BANK-REF-123456 + + + PMNT + + ICDT + ESCT + + + + + + + NOTPROVIDED + TXN-ID-789 + MANDATE-REF-456 + + + + 249.75 + + + + SEPA Instant Transfer Payment Reference Text + + + + Max Mustermann + + + + DE89370400440532013000 + + + + Erika Musterfrau + + + + DE12500105170648489890 + + + + + + + COBADEFFXXX + + + + + INGDDEFFXXX + + + + + CBFF + + + + Additional payment information from bank +
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + const statement = statements[0]; + + // Test all Statement fields with comprehensive data + expect(statement.account).toBe('DE89370400440532013000'); + expect(statement.number).toBe('COMPREHENSIVE_TEST'); + expect(statement.transactionReference).toBe('987654321'); + expect(statement.relatedReference).toBeUndefined(); + expect(statement.forwardBalances).toBeUndefined(); + + // Test opening balance with exact date parsing + expect(statement.openingBalance).toBeDefined(); + expect(statement.openingBalance.value).toBe(5000.5); + expect(statement.openingBalance.currency).toBe('EUR'); + expect(statement.openingBalance.date.getFullYear()).toBe(2023); + expect(statement.openingBalance.date.getMonth()).toBe(11); // December (0-based) + expect(statement.openingBalance.date.getDate()).toBe(21); + + // Test closing balance + expect(statement.closingBalance).toBeDefined(); + expect(statement.closingBalance.value).toBe(4750.75); + expect(statement.closingBalance.currency).toBe('EUR'); + expect(statement.closingBalance.date.getFullYear()).toBe(2023); + expect(statement.closingBalance.date.getMonth()).toBe(11); // December (0-based) + expect(statement.closingBalance.date.getDate()).toBe(22); + + // Test available balance (ITBD) + expect(statement.availableBalance).toBeDefined(); + expect(statement.availableBalance!.value).toBe(4500.0); + expect(statement.availableBalance!.currency).toBe('EUR'); + + // Test transaction with comprehensive fields + expect(statement.transactions).toHaveLength(1); + const transaction = statement.transactions[0]; + + // Amount and basic fields + expect(transaction.amount).toBe(-249.75); // Negative for debit + expect(transaction.customerReference).toBe('NOTPROVIDED'); + expect(transaction.bankReference).toBe('BANK-REF-123456'); + expect(transaction.purpose).toBe('SEPA Instant Transfer Payment Reference Text'); + + // Party information (debit transaction - creditor is remote party) + expect(transaction.remoteName).toBe('Erika Musterfrau'); + expect(transaction.remoteAccountNumber).toBe('DE12500105170648489890'); + expect(transaction.remoteBankId).toBe('INGDDEFFXXX'); // CdtrAgt BIC for debit + + // Reference fields + expect(transaction.e2eReference).toBe('NOTPROVIDED'); + expect(transaction.mandateReference).toBe('MANDATE-REF-456'); + + // Date fields with exact verification + expect(transaction.valueDate).toBeInstanceOf(Date); + expect(transaction.valueDate.getFullYear()).toBe(2023); + expect(transaction.valueDate.getMonth()).toBe(11); // December + expect(transaction.valueDate.getDate()).toBe(22); + expect(transaction.entryDate).toBeInstanceOf(Date); + expect(transaction.entryDate.getFullYear()).toBe(2023); + expect(transaction.entryDate.getMonth()).toBe(11); + expect(transaction.entryDate.getDate()).toBe(22); + + // Bank transaction code structure + expect(transaction.fundsCode).toBe('PMNT'); // Domain code + expect(transaction.transactionType).toBe('ICDT'); // Family code + expect(transaction.transactionCode).toBe('ESCT'); // SubFamily code + + // Additional information + expect(transaction.additionalInformation).toBe('Additional payment information from bank'); + expect(transaction.bookingText).toBe('Additional payment information from bank'); + + // Verify undefined optional fields + expect(transaction.primeNotesNr).toBeUndefined(); + expect(transaction.remoteIdentifier).toBeUndefined(); + expect(transaction.client).toBeUndefined(); + expect(transaction.textKeyExtension).toBeUndefined(); + }); + + it('should handle edge cases and missing optional fields correctly', () => { + const camtXml = ` + + + + edge-case-test + + + DE99999999999999999999 + + USD + + + PRCD + 0.01 + DBIT +
20231222
+
+ + CLBD + 999.99 + CRDT +
20231222
+
+ + 1000.00 + CRDT +
20231222
+
20231221
+ + + + + + + + + + + + + + + + +
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + const statement = statements[0]; + + // Test edge case balances + expect(statement.openingBalance.value).toBe(-0.01); // Negative due to DBIT indicator + expect(statement.openingBalance.currency).toBe('USD'); + expect(statement.closingBalance.value).toBe(999.99); + expect(statement.closingBalance.currency).toBe('USD'); + + // Test date parsing from YYYYMMDD format + expect(statement.openingBalance.date.getFullYear()).toBe(2023); + expect(statement.openingBalance.date.getMonth()).toBe(11); // December + expect(statement.openingBalance.date.getDate()).toBe(22); + + // Test transaction with mostly empty/missing fields + expect(statement.transactions).toHaveLength(1); + const transaction = statement.transactions[0]; + + expect(transaction.amount).toBe(1000.0); + expect(transaction.customerReference).toBe(''); // Empty EndToEndId + expect(transaction.bankReference).toBe(''); // Empty AcctSvcrRef + expect(transaction.purpose).toBe(''); // Empty Ustrd + expect(transaction.remoteName).toBe(''); // Empty Nm + expect(transaction.remoteAccountNumber).toBe(''); // No IBAN provided + expect(transaction.remoteBankId).toBe(''); // No BIC provided + expect(transaction.e2eReference).toBe(''); // Empty EndToEndId + expect(transaction.mandateReference).toBe(''); // No MndtId + + // Test date parsing consistency + expect(transaction.valueDate.getDate()).toBe(21); // Different from entry date + expect(transaction.entryDate.getDate()).toBe(22); + + // Test transaction type fields with no BkTxCd + expect(transaction.fundsCode).toBe('CRDT'); + expect(transaction.transactionType).toBe(''); + expect(transaction.transactionCode).toBe(''); - expect(statements).toHaveLength(2); - expect(statements[0].account).toBe('DE11111111111111111111'); - expect(statements[1].account).toBe('DE22222222222222222222'); - }); + expect(transaction.additionalInformation).toBe(''); + expect(transaction.bookingText).toBe(''); + }); }); From 5ab50172e3586133c86cc594edea7c41675b0129 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Mon, 22 Dec 2025 14:39:21 +0100 Subject: [PATCH 4/6] replaced regex parsing with fast-xml-parser. --- .github/copilot-instructions.md | 12 +- package.json | 3 + pnpm-lock.yaml | 17 + src/camtParser.ts | 434 +++++++++++++------ src/interactions/statementInteractionCAMT.ts | 80 ++-- src/tests/HICAZS.test.ts | 26 +- src/tests/HKCAZ.test.ts | 90 ++-- src/tests/camtParser.test.ts | 13 +- 8 files changed, 425 insertions(+), 250 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3bcca17..1b8e2fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,7 +27,7 @@ All transactions may require two-step TAN process: ```typescript let response = await client.getAccountStatements(account); if (response.requiresTan) { - response = await client.getAccountStatementsWithTan(response.tanReference, userTAN); + response = await client.getAccountStatementsWithTan(response.tanReference, userTAN); } ``` @@ -49,8 +49,8 @@ new DataGroup('acceptedFormats', [new Text('format', 1, 99)], 1, 1); // ✅ Direct element with maxCount>1 as last element in segment elements = [ - new Text('someField', 1, 1), - new Binary('transactions', 0, 10000), // Last element can have maxCount>1 + new Text('someField', 1, 1), + new Binary('transactions', 0, 10000), // Last element can have maxCount>1 ]; ``` @@ -59,8 +59,8 @@ elements = [ ```typescript // ❌ DataElement with maxCount>1 not as last element elements = [ - new Text('formats', 1, 99), // maxCount>1 but not last! - new YesNo('someFlag', 1, 1), // This breaks parsing + new Text('formats', 1, 99), // maxCount>1 but not last! + new YesNo('someFlag', 1, 1), // This breaks parsing ]; ``` @@ -69,7 +69,7 @@ elements = [ - Use Vitest with mock patterns for `Dialog.prototype` methods - Mock external HTTP communication, not internal protocol logic - Test files follow `*.test.ts` naming in `src/tests/` -- Run tests: `pnpm test` +- Run tests: `pnpm test run` ### Error Handling diff --git a/package.json b/package.json index aede154..ffbf7ae 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,8 @@ "@types/node": "^20.19.26", "typescript": "^5.3.3", "vitest": "^1.6.0" + }, + "dependencies": { + "fast-xml-parser": "^5.3.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 255e550..8cf1913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + fast-xml-parser: + specifier: ^5.3.3 + version: 5.3.3 devDependencies: '@types/node': specifier: ^20.19.26 @@ -314,6 +318,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + fast-xml-parser@5.3.3: + resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} + hasBin: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -452,6 +460,9 @@ packages: strip-literal@2.1.0: resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + tinybench@2.5.1: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} @@ -785,6 +796,10 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + fast-xml-parser@5.3.3: + dependencies: + strnum: 2.1.2 + fsevents@2.3.3: optional: true @@ -913,6 +928,8 @@ snapshots: dependencies: js-tokens: 9.0.0 + strnum@2.1.2: {} + tinybench@2.5.1: {} tinypool@0.8.3: {} diff --git a/src/camtParser.ts b/src/camtParser.ts index 5ce066d..de06402 100644 --- a/src/camtParser.ts +++ b/src/camtParser.ts @@ -1,191 +1,363 @@ import { Statement, Transaction, Balance } from './statement.js'; +import { XMLParser, XMLValidator } from 'fast-xml-parser'; + +export class CamtParsingError extends Error { + constructor(message: string, public cause?: Error) { + super(message); + this.name = 'CamtParsingError'; + } +} export class CamtParser { private xmlData: string; + private parser: XMLParser; constructor(xmlData: string) { this.xmlData = xmlData; + this.parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + textNodeName: '#text', + removeNSPrefix: true, + parseAttributeValue: true, + trimValues: true, + parseTagValue: false, // Don't auto-parse values to preserve strings like "00001" + processEntities: true, + allowBooleanAttributes: false, + numberParseOptions: { + hex: false, + leadingZeros: true, + eNotation: true, + }, + }); } parse(): Statement[] { try { + // Pre-validate XML + const validationResult = XMLValidator.validate(this.xmlData); + if (validationResult !== true) { + throw new CamtParsingError(`Invalid CAMT XML structure: ${validationResult.err.msg}`); + } + + // Parse XML to JavaScript object + const document = this.parser.parse(this.xmlData); + + // Navigate to Document/BkToCstmrStmt/Stmt array const statements: Statement[] = []; + const docObj = this.getDocumentObject(document); + const reports = this.getReports(docObj); - // Parse multiple reports using regex (CAMT.053 can contain multiple reports) - const reportMatches = this.xmlData.match(/[\s\S]*?<\/Rpt>/g); - if (!reportMatches) { + if (!reports || reports.length === 0) { return statements; } - for (const reportXml of reportMatches) { - const statement = this.parseReport(reportXml); - if (statement) { - statements.push(statement); + for (let i = 0; i < reports.length; i++) { + try { + const statement = this.parseReport(reports[i], i + 1); + if (statement) { + statements.push(statement); + } + } catch (error) { + throw new CamtParsingError( + `Failed to parse CAMT report ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`, + error instanceof Error ? error : undefined + ); } } return statements; } catch (error) { - throw new Error(`Failed to parse CAMT data: ${error instanceof Error ? error.message : 'Unknown error'}`); + if (error instanceof CamtParsingError) { + throw error; + } + throw new CamtParsingError( + `Failed to parse CAMT document: ${error instanceof Error ? error.message : 'Unknown error'}`, + error instanceof Error ? error : undefined + ); } } - private parseReport(reportXml: string): Statement | null { - // Extract account information - const account = this.extractTagValue(reportXml, 'IBAN'); - - // Extract statement number/ID - const number = this.extractTagValue(reportXml, 'Id'); - - // Extract transaction reference - const transactionReference = this.extractTagValue(reportXml, 'ElctrncSeqNb'); + private getDocumentObject(document: any): any { + // Handle different possible XML root structures + if (document.Document) { + return document.Document; + } + if (document.camt) { + return document.camt; + } + // Look for any object with BkToCstmrAcctRpt property + for (const key in document) { + if (document[key] && document[key].BkToCstmrAcctRpt) { + return document[key]; + } + } + throw new CamtParsingError('No valid CAMT document structure found'); + } - // Parse balances - const balances = this.parseBalances(reportXml); - if (!balances.openingBalance || !balances.closingBalance) { - return null; // Need at least opening and closing balance + private getReports(docObj: any): any[] { + const bkToCstmrAcctRpt = docObj.BkToCstmrAcctRpt; + if (!bkToCstmrAcctRpt) { + throw new CamtParsingError('No BkToCstmrAcctRpt element found in CAMT document'); } - // Parse transactions - const transactions = this.parseTransactions(reportXml); + const rpt = bkToCstmrAcctRpt.Rpt; + if (!rpt) { + return []; + } - return { - account, - number, - transactionReference, - openingBalance: balances.openingBalance, - closingBalance: balances.closingBalance, - availableBalance: balances.availableBalance, - transactions, - }; + // Handle both single report and array of reports + return Array.isArray(rpt) ? rpt : [rpt]; } - private parseBalances(reportXml: string): { - openingBalance?: Balance; - closingBalance?: Balance; - availableBalance?: Balance; - } { - let openingBalance: Balance | undefined; - let closingBalance: Balance | undefined; - let availableBalance: Balance | undefined; + private parseReport(report: any, reportNumber: number): Statement | null { + try { + // Extract account information + const account = this.getValueFromPath(report, 'Acct.Id.IBAN'); - // Extract all balance elements - const balanceMatches = reportXml.match(//g); - if (!balanceMatches) { - return { openingBalance, closingBalance, availableBalance }; - } + // Extract statement number/ID + const number = this.getValueFromPath(report, 'Id'); + + // Extract transaction reference + const transactionReference = this.getValueFromPath(report, 'ElctrncSeqNb'); + + // Parse balances + const balances = this.parseBalances(report, reportNumber); + + // Be more flexible with balance requirements - some banks only provide one balance + let openingBalance = balances.openingBalance; + let closingBalance = balances.closingBalance; - for (const balanceXml of balanceMatches) { - const typeCode = this.extractTagValue(balanceXml, 'Cd'); + // If we don't have both opening and closing, try to use what we have + if (!openingBalance && !closingBalance) { + // If we have available balance, use it as closing balance + if (balances.availableBalance) { + closingBalance = balances.availableBalance; + } else { + throw new CamtParsingError(`No balance information found in CAMT report ${reportNumber}`); + } + } - // Extract amount and currency - const amtMatch = balanceXml.match(/([^<]+)<\/Amt>/); - const currency = amtMatch ? amtMatch[1] : 'EUR'; - const value = amtMatch ? parseFloat(amtMatch[2]) : 0; + // If missing opening balance, create a zero balance for the same date as closing + if (!openingBalance && closingBalance) { + openingBalance = { + date: closingBalance.date, + currency: closingBalance.currency, + value: 0, + }; + } - const creditDebitInd = this.extractTagValue(balanceXml, 'CdtDbtInd'); - const finalValue = creditDebitInd === 'DBIT' ? -value : value; + // If missing closing balance, use opening balance as closing + if (!closingBalance && openingBalance) { + closingBalance = openingBalance; + } - const dateStr = this.extractTagValue(balanceXml, 'Dt'); - const date = dateStr ? this.parseDate(dateStr) : new Date(); + // Parse transactions + const transactions = this.parseTransactions(report, reportNumber); - const balance: Balance = { - date, - currency, - value: finalValue, + return { + account, + number, + transactionReference, + openingBalance: openingBalance!, + closingBalance: closingBalance!, + availableBalance: balances.availableBalance, + transactions, }; + } catch (error) { + if (error instanceof CamtParsingError) { + throw error; + } + throw new CamtParsingError( + `Failed to parse report ${reportNumber} content: ${error instanceof Error ? error.message : 'Unknown error'}`, + error instanceof Error ? error : undefined + ); + } + } - switch (typeCode) { - case 'PRCD': // Previous closing date - openingBalance = balance; - break; - case 'CLBD': // Closing booked - closingBalance = balance; - break; - case 'ITBD': // Interim booked - case 'FWAV': // Forward available - availableBalance = balance; - break; + private getValueFromPath(obj: any, path: string): string | undefined { + const pathParts = path.split('.'); + let current = obj; + + for (const part of pathParts) { + if (current && typeof current === 'object' && current[part] !== undefined) { + current = current[part]; + } else { + return undefined; } } - return { openingBalance, closingBalance, availableBalance }; + if (typeof current === 'string' || typeof current === 'number') { + return String(current); + } + if (current && typeof current === 'object' && current['#text'] !== undefined) { + return String(current['#text']); + } + + return undefined; } - private parseTransactions(reportXml: string): Transaction[] { + private parseBalances( + report: any, + reportNumber: number + ): { + openingBalance?: Balance; + closingBalance?: Balance; + availableBalance?: Balance; + } { + try { + let openingBalance: Balance | undefined; + let closingBalance: Balance | undefined; + let availableBalance: Balance | undefined; + + // Get balance array from report + const balances = report.Bal; + if (!balances) { + return { openingBalance, closingBalance, availableBalance }; + } + + const balanceArray = Array.isArray(balances) ? balances : [balances]; + + for (const balanceObj of balanceArray) { + const typeCode = this.getValueFromPath(balanceObj, 'Tp.CdOrPrtry.Cd'); + + // Extract amount and currency + const currency = balanceObj.Amt?.['@Ccy'] || 'EUR'; + const value = parseFloat(this.getValueFromPath(balanceObj, 'Amt') || '0'); + + const creditDebitInd = this.getValueFromPath(balanceObj, 'CdtDbtInd'); + const finalValue = creditDebitInd === 'DBIT' ? -value : value; + + const dateStr = this.getValueFromPath(balanceObj, 'Dt.Dt') || this.getValueFromPath(balanceObj, 'Dt'); + const date = dateStr ? this.parseDate(dateStr) : new Date(); + + const balance: Balance = { + date, + currency, + value: finalValue, + }; + + switch (typeCode) { + case 'PRCD': // Previous closing date + case 'OPBD': // Opening booked + case 'OPAV': // Opening available + openingBalance = balance; + break; + case 'CLBD': // Closing booked + case 'CLAV': // Closing available + closingBalance = balance; + break; + case 'ITBD': // Interim booked + case 'ITAV': // Interim available + case 'FWAV': // Forward available + case 'BOOK': // Booked balance + // Use as available balance, or as closing if we don't have one + if (!availableBalance) { + availableBalance = balance; + } + // If we don't have a closing balance, use this as closing + if (!closingBalance && (typeCode === 'BOOK' || typeCode === 'ITBD')) { + closingBalance = balance; + } + break; + default: + // Handle unknown balance types by using them as closing balance if we don't have one + if (!closingBalance) { + closingBalance = balance; + } + break; + } + } + + return { openingBalance, closingBalance, availableBalance }; + } catch (error) { + throw new CamtParsingError( + `Failed to parse balances in report ${reportNumber}: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + error instanceof Error ? error : undefined + ); + } + } + + private parseTransactions(report: any, reportNumber: number): Transaction[] { const transactions: Transaction[] = []; - const entryMatches = reportXml.match(//g); + const entries = report.Ntry; - if (!entryMatches) { + if (!entries) { return transactions; } - for (const entryXml of entryMatches) { - const transaction = this.parseTransaction(entryXml); - if (transaction) { - transactions.push(transaction); + const entryArray = Array.isArray(entries) ? entries : [entries]; + + for (let i = 0; i < entryArray.length; i++) { + try { + const transaction = this.parseTransaction(entryArray[i], reportNumber, i + 1); + if (transaction) { + transactions.push(transaction); + } + } catch (error) { + throw new CamtParsingError( + `Failed to parse transaction ${i + 1} in report ${reportNumber}: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + error instanceof Error ? error : undefined + ); } } return transactions; } - private parseTransaction(entryXml: string): Transaction | null { + private parseTransaction(entry: any, reportNumber: number, transactionNumber: number): Transaction | null { try { // Extract amount and credit/debit indicator - const amtMatch = entryXml.match(/]*>([^<]+)<\/Amt>/); - const amountValue = amtMatch ? parseFloat(amtMatch[1]) : 0; - const creditDebitInd = this.extractTagValue(entryXml, 'CdtDbtInd'); + const amountValue = parseFloat(this.getValueFromPath(entry, 'Amt') || '0'); + const creditDebitInd = this.getValueFromPath(entry, 'CdtDbtInd'); const isDebit = creditDebitInd === 'DBIT'; const amount = isDebit ? -amountValue : amountValue; // Extract dates - const bookingDate = this.extractNestedTagValue(entryXml, 'BookgDt', 'Dt'); - const valueDate = this.extractNestedTagValue(entryXml, 'ValDt', 'Dt'); + const bookingDate = this.getValueFromPath(entry, 'BookgDt.Dt') || this.getValueFromPath(entry, 'BookgDt'); + const valueDate = this.getValueFromPath(entry, 'ValDt.Dt') || this.getValueFromPath(entry, 'ValDt'); const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date(); const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate; // Extract references - const accountServicerRef = this.extractTagValue(entryXml, 'AcctSvcrRef') || ''; - const endToEndId = this.extractTagValue(entryXml, 'EndToEndId') || ''; - const mandateId = this.extractTagValue(entryXml, 'MndtId') || ''; + const accountServicerRef = this.getValueFromPath(entry, 'AcctSvcrRef') || ''; + const endToEndId = this.getValueFromPath(entry, 'NtryDtls.TxDtls.Refs.EndToEndId') || ''; + const mandateId = this.getValueFromPath(entry, 'NtryDtls.TxDtls.Refs.MndtId') || ''; // Extract transaction details - const additionalEntryInfo = this.extractTagValue(entryXml, 'AddtlNtryInf') || ''; - const remittanceInfo = this.extractTagValue(entryXml, 'Ustrd') || ''; + const additionalEntryInfo = this.getValueFromPath(entry, 'AddtlNtryInf') || ''; + const remittanceInfo = this.getValueFromPath(entry, 'NtryDtls.TxDtls.RmtInf.Ustrd') || ''; // Extract remote party information based on transaction type let remoteName = ''; let remoteIBAN = ''; let remoteBankId = ''; - if (isDebit) { - // For debit transactions, we want the creditor (receiving party) - const creditorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Cdtr>/); - remoteName = creditorNameMatch ? creditorNameMatch[1] : ''; - - const creditorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/CdtrAcct>/); - remoteIBAN = creditorIbanMatch ? creditorIbanMatch[1] : ''; - - // For debit, get creditor's bank BIC - const creditorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/CdtrAgt>/); - remoteBankId = creditorBicMatch ? creditorBicMatch[1] : ''; - } else { - // For credit transactions, we want the debtor (sending party) - const debtorNameMatch = entryXml.match(/[\s\S]*?([^<]+)<\/Nm>[\s\S]*?<\/Dbtr>/); - remoteName = debtorNameMatch ? debtorNameMatch[1] : ''; - - const debtorIbanMatch = entryXml.match(/[\s\S]*?([^<]+)<\/IBAN>[\s\S]*?<\/DbtrAcct>/); - remoteIBAN = debtorIbanMatch ? debtorIbanMatch[1] : ''; - - // For credit, get debtor's bank BIC - const debtorBicMatch = entryXml.match(/[\s\S]*?([^<]+)<\/BIC>[\s\S]*?<\/DbtrAgt>/); - remoteBankId = debtorBicMatch ? debtorBicMatch[1] : ''; + const txDtls = entry.NtryDtls?.TxDtls; + if (txDtls) { + if (isDebit) { + // For debit transactions, we want the creditor (receiving party) + remoteName = this.getValueFromPath(txDtls, 'RltdPties.Cdtr.Nm') || ''; + remoteIBAN = this.getValueFromPath(txDtls, 'RltdPties.CdtrAcct.Id.IBAN') || ''; + remoteBankId = this.getValueFromPath(txDtls, 'RltdAgts.CdtrAgt.FinInstnId.BIC') || ''; + } else { + // For credit transactions, we want the debtor (sending party) + remoteName = this.getValueFromPath(txDtls, 'RltdPties.Dbtr.Nm') || ''; + remoteIBAN = this.getValueFromPath(txDtls, 'RltdPties.DbtrAcct.Id.IBAN') || ''; + remoteBankId = this.getValueFromPath(txDtls, 'RltdAgts.DbtrAgt.FinInstnId.BIC') || ''; + } } - // Extract bank transaction code structure (BkTxCd) - const bkTxCd = this.parseBankTransactionCode(entryXml); + // Extract bank transaction code structure (BkTxCd) - can be at entry level or TxDtls level + let bkTxCd = this.parseBankTransactionCode(entry); + if (!bkTxCd.domainCode && !bkTxCd.familyCode && !bkTxCd.subFamilyCode && txDtls) { + bkTxCd = this.parseBankTransactionCode(txDtls); + } return { valueDate: parsedValueDate, @@ -206,8 +378,10 @@ export class CamtParser { bookingText: additionalEntryInfo, }; } catch (error) { - console.warn('Failed to parse CAMT transaction entry:', error); - return null; + throw new CamtParsingError( + `Failed to parse transaction details: ${error instanceof Error ? error.message : 'Unknown error'}`, + error instanceof Error ? error : undefined + ); } } @@ -228,42 +402,24 @@ export class CamtParser { return new Date(dateStr); } - private extractTagValue(xml: string, tagName: string): string | undefined { - const pattern = new RegExp(`<${tagName}>([^<]*)<\\/${tagName}>`, 'i'); - const match = xml.match(pattern); - return match ? match[1] : undefined; - } - - private extractNestedTagValue(xml: string, parentTag: string, childTag: string): string | undefined { - const parentPattern = new RegExp(`<${parentTag}[\\s\\S]*?<\\/${parentTag}>`, 'i'); - const parentMatch = xml.match(parentPattern); - if (!parentMatch) { - return undefined; - } - return this.extractTagValue(parentMatch[0], childTag); - } - - private parseBankTransactionCode(entryXml: string): { + private parseBankTransactionCode(entry: any): { domainCode?: string; familyCode?: string; subFamilyCode?: string; } { - // Extract the entire BkTxCd block - const bkTxCdMatch = entryXml.match(/[\s\S]*?<\/BkTxCd>/); - if (!bkTxCdMatch) { + const bkTxCd = entry.BkTxCd; + if (!bkTxCd) { return {}; } - const bkTxCdXml = bkTxCdMatch[0]; - // Extract Domain Code (first level - e.g., "PMNT") - const domainCode = this.extractNestedTagValue(bkTxCdXml, 'Domn', 'Cd'); + const domainCode = this.getValueFromPath(bkTxCd, 'Domn.Cd'); // Extract Family Code (second level - e.g., "CCRD") - const familyCode = this.extractNestedTagValue(bkTxCdXml, 'Fmly', 'Cd'); + const familyCode = this.getValueFromPath(bkTxCd, 'Domn.Fmly.Cd'); // Extract SubFamily Code (third level - e.g., "POSD") - const subFamilyCode = this.extractTagValue(bkTxCdXml, 'SubFmlyCd'); + const subFamilyCode = this.getValueFromPath(bkTxCd, 'Domn.Fmly.SubFmlyCd'); return { domainCode, diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index 9767fff..2973527 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -8,49 +8,49 @@ import { Segment } from '../segment.js'; import { FinTSConfig } from '../config.js'; export class StatementInteractionCAMT extends CustomerOrderInteraction { - private acceptedCamtFormats: string[] = ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08']; + private acceptedCamtFormats: string[] = ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08']; - constructor(public accountNumber: string, public from?: Date, public to?: Date) { - super(HKCAZ.Id, HICAZ.Id); - } + constructor(public accountNumber: string, public from?: Date, public to?: Date) { + super(HKCAZ.Id, HICAZ.Id); + } - createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); - const version = init.getMaxSupportedTransactionVersion(HKCAZ.Id); - if (!version) { - throw Error(`There is no supported version for business transaction '${HKCAZ.Id}'`); - } + createSegments(init: FinTSConfig): Segment[] { + const bankAccount = init.getBankAccount(this.accountNumber); + const version = init.getMaxSupportedTransactionVersion(HKCAZ.Id); + if (!version) { + throw Error(`There is no supported version for business transaction '${HKCAZ.Id}'`); + } - const hkcaz: HKCAZSegment = { - header: { segId: HKCAZ.Id, segNr: 0, version: version }, - account: bankAccount, - acceptedCamtFormats: this.acceptedCamtFormats, - allAccounts: false, - from: this.from, - to: this.to, - }; + const hkcaz: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 0, version: version }, + account: bankAccount, + acceptedCamtFormats: this.acceptedCamtFormats, + allAccounts: false, + from: this.from, + to: this.to, + }; - return [hkcaz]; - } + return [hkcaz]; + } - handleResponse(response: Message, clientResponse: StatementResponse) { - const hicaz = response.findSegment(HICAZ.Id); - if (hicaz && hicaz.bookedTransactions && hicaz.bookedTransactions.length > 0) { - try { - // Parse all CAMT messages (one per booking day) and combine statements - const allStatements: Statement[] = []; - for (const camtMessage of hicaz.bookedTransactions) { - const parser = new CamtParser(camtMessage); - const statements = parser.parse(); - allStatements.push(...statements); - } - clientResponse.statements = allStatements; - } catch (error) { - console.warn('CAMT parsing failed:', error); - clientResponse.statements = []; - } - } else { - clientResponse.statements = []; - } - } + handleResponse(response: Message, clientResponse: StatementResponse) { + const hicaz = response.findSegment(HICAZ.Id); + if (hicaz && hicaz.bookedTransactions && hicaz.bookedTransactions.length > 0) { + try { + // Parse all CAMT messages (one per booking day) and combine statements + const allStatements: Statement[] = []; + for (const camtMessage of hicaz.bookedTransactions) { + const parser = new CamtParser(camtMessage); + const statements = parser.parse(); + allStatements.push(...statements); + } + clientResponse.statements = allStatements; + } catch (error) { + console.warn('CAMT parsing failed:', error); + clientResponse.statements = []; + } + } else { + clientResponse.statements = []; + } + } } diff --git a/src/tests/HICAZS.test.ts b/src/tests/HICAZS.test.ts index 5285c9c..9732592 100644 --- a/src/tests/HICAZS.test.ts +++ b/src/tests/HICAZS.test.ts @@ -6,19 +6,19 @@ import { HICAZSSegment } from '../segments/HICAZS.js'; registerSegments(); describe('HICAZS', () => { - it('decode and encode roundtrip matches', () => { - const text = - "HICAZS:16:1:4+1+1+0+450:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02'"; - const segment = decode(text) as HICAZSSegment; + it('decode and encode roundtrip matches', () => { + const text = + "HICAZS:16:1:4+1+1+0+450:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08'"; + const segment = decode(text) as HICAZSSegment; - expect(segment.params.maxDays).toBe(450); - expect(segment.params.entryCountAllowed).toBeFalsy(); - expect(segment.params.allAccountsAllowed).toBeFalsy(); - expect(segment.params.supportedCamtFormats).toEqual([ - 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.08', - 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.02', - ]); + expect(segment.params.maxDays).toBe(450); + expect(segment.params.entryCountAllowed).toBeFalsy(); + expect(segment.params.allAccountsAllowed).toBeFalsy(); + expect(segment.params.supportedCamtFormats).toEqual([ + 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.08', + 'urn:iso:std:iso:20022:tech:xsd:camt.052.001.08', + ]); - expect(encode(segment)).toBe(text); - }); + expect(encode(segment)).toBe(text); + }); }); diff --git a/src/tests/HKCAZ.test.ts b/src/tests/HKCAZ.test.ts index 1cfd5ea..bc30f4c 100644 --- a/src/tests/HKCAZ.test.ts +++ b/src/tests/HKCAZ.test.ts @@ -6,54 +6,54 @@ import { decode, encode } from '../segment.js'; registerSegments(); describe('HKCAZ v1', () => { - it('encode', () => { - const segment: HKCAZSegment = { - header: { segId: HKCAZ.Id, segNr: 1, version: 1 }, - account: { - iban: 'DE991234567123456', - bic: 'BANK12', - accountNumber: '123456', - bank: { country: 280, bankId: '12030000' }, - }, - acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02'], - allAccounts: false, - from: new Date('2023-01-01'), - to: new Date('2023-12-31'), - }; + it('encode', () => { + const segment: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 1, version: 1 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + accountNumber: '123456', + bank: { country: 280, bankId: '12030000' }, + }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], + allAccounts: false, + from: new Date('2023-01-01'), + to: new Date('2023-12-31'), + }; - expect(encode(segment)).toBe( - "HKCAZ:1:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20230101+20231231'" - ); - }); + expect(encode(segment)).toBe( + "HKCAZ:1:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'" + ); + }); - it('encode without optional dates', () => { - const segment: HKCAZSegment = { - header: { segId: HKCAZ.Id, segNr: 2, version: 1 }, - account: { - iban: 'DE991234567123456', - bic: 'BANK12', - accountNumber: '123456', - bank: { country: 280, bankId: '12030000' }, - }, - acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.02'], - allAccounts: true, - }; + it('encode without optional dates', () => { + const segment: HKCAZSegment = { + header: { segId: HKCAZ.Id, segNr: 2, version: 1 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + accountNumber: '123456', + bank: { country: 280, bankId: '12030000' }, + }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], + allAccounts: true, + }; - expect(encode(segment)).toBe( - "HKCAZ:2:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+J'" - ); - }); + expect(encode(segment)).toBe( + "HKCAZ:2:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'" + ); + }); - it('decode and encode roundtrip matches', () => { - const text = - "HKCAZ:0:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+N+20230101+20231231'"; - const segment = decode(text); - expect(encode(segment)).toBe(text); - }); + it('decode and encode roundtrip matches', () => { + const text = + "HKCAZ:0:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'"; + const segment = decode(text); + expect(encode(segment)).toBe(text); + }); - it('decode and encode roundtrip without dates', () => { - const text = "HKCAZ:0:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02+J'"; - const segment = decode(text); - expect(encode(segment)).toBe(text); - }); + it('decode and encode roundtrip without dates', () => { + const text = "HKCAZ:0:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'"; + const segment = decode(text); + expect(encode(segment)).toBe(text); + }); }); diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts index 72eee3f..87943a2 100644 --- a/src/tests/camtParser.test.ts +++ b/src/tests/camtParser.test.ts @@ -4,7 +4,7 @@ import { CamtParser } from '../camtParser.js'; describe('CamtParser', () => { it('should parse CAMT.052 XML with balances and transactions', () => { const camtXml = ` - + camt52_20131118101510__ONLINEBA @@ -183,7 +183,7 @@ describe('CamtParser', () => { it('should handle debit transactions correctly', () => { const camtXml = ` - + test @@ -303,13 +303,12 @@ describe('CamtParser', () => { it('should handle empty or invalid XML gracefully', () => { const parser = new CamtParser('invalid xml'); - const statements = parser.parse(); - expect(statements).toHaveLength(0); // Should return empty array instead of throwing + expect(() => parser.parse()).toThrow(); // Should throw error for invalid XML }); it('should handle multiple reports', () => { const camtXml = ` - + report1 @@ -336,7 +335,7 @@ describe('CamtParser', () => { it('should parse comprehensive CAMT XML with all possible fields and bank transaction codes', () => { const camtXml = ` - + comprehensive_test @@ -544,7 +543,7 @@ describe('CamtParser', () => { it('should handle edge cases and missing optional fields correctly', () => { const camtXml = ` - + edge-case-test From 8dee1d1373987359cf72098cd711f51563fb9647 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Mon, 22 Dec 2025 16:43:17 +0100 Subject: [PATCH 5/6] Enhance CamtParser to better handle structure variations and add corresponding tests --- src/camtParser.ts | 84 ++++++++++++++++++++++++++++++-- src/tests/camtParser.test.ts | 94 ++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/src/camtParser.ts b/src/camtParser.ts index de06402..8221042 100644 --- a/src/camtParser.ts +++ b/src/camtParser.ts @@ -342,14 +342,14 @@ export class CamtParser { if (txDtls) { if (isDebit) { // For debit transactions, we want the creditor (receiving party) - remoteName = this.getValueFromPath(txDtls, 'RltdPties.Cdtr.Nm') || ''; + remoteName = this.extractPartyName(txDtls, 'RltdPties.Cdtr'); remoteIBAN = this.getValueFromPath(txDtls, 'RltdPties.CdtrAcct.Id.IBAN') || ''; - remoteBankId = this.getValueFromPath(txDtls, 'RltdAgts.CdtrAgt.FinInstnId.BIC') || ''; + remoteBankId = this.extractBankId(txDtls, 'RltdAgts.CdtrAgt.FinInstnId'); } else { // For credit transactions, we want the debtor (sending party) - remoteName = this.getValueFromPath(txDtls, 'RltdPties.Dbtr.Nm') || ''; + remoteName = this.extractPartyName(txDtls, 'RltdPties.Dbtr'); remoteIBAN = this.getValueFromPath(txDtls, 'RltdPties.DbtrAcct.Id.IBAN') || ''; - remoteBankId = this.getValueFromPath(txDtls, 'RltdAgts.DbtrAgt.FinInstnId.BIC') || ''; + remoteBankId = this.extractBankId(txDtls, 'RltdAgts.DbtrAgt.FinInstnId'); } } @@ -385,6 +385,82 @@ export class CamtParser { } } + /** + * Extract party name from various possible CAMT structures + * Handles both direct name () and party structure () + */ + private extractPartyName(txDtls: any, partyPath: string): string { + // Strategy 1: Direct name structure (e.g., RltdPties.Dbtr.Nm) + let name = this.getValueFromPath(txDtls, `${partyPath}.Nm`); + if (name) { + return name; + } + + // Strategy 2: Party structure (e.g., RltdPties.Dbtr.Pty.Nm) + name = this.getValueFromPath(txDtls, `${partyPath}.Pty.Nm`); + if (name) { + return name; + } + + // Strategy 3: Organization ID structure (e.g., RltdPties.Dbtr.Id.OrgId.Nm) + name = this.getValueFromPath(txDtls, `${partyPath}.Id.OrgId.Nm`); + if (name) { + return name; + } + + // Strategy 4: Private ID structure (e.g., RltdPties.Dbtr.Id.PrvtId.Nm) + name = this.getValueFromPath(txDtls, `${partyPath}.Id.PrvtId.Nm`); + if (name) { + return name; + } + + // Strategy 5: Try postal address line as fallback + name = this.getValueFromPath(txDtls, `${partyPath}.PstlAdr.AdrLine`); + if (name) { + return name; + } + + // Strategy 6: Try organization identification other + name = this.getValueFromPath(txDtls, `${partyPath}.Id.OrgId.Othr.Id`); + if (name) { + return name; + } + + return ''; + } + + /** + * Extract bank identification code from various possible CAMT structures + * Handles both BIC and BICFI elements + */ + private extractBankId(txDtls: any, bankPath: string): string { + // Strategy 1: Standard BIC element + let bankId = this.getValueFromPath(txDtls, `${bankPath}.BIC`); + if (bankId) { + return bankId; + } + + // Strategy 2: BICFI element (used by some banks) + bankId = this.getValueFromPath(txDtls, `${bankPath}.BICFI`); + if (bankId) { + return bankId; + } + + // Strategy 3: Try ClrSysMmbId (clearing system member identification) + bankId = this.getValueFromPath(txDtls, `${bankPath}.ClrSysMmbId.MmbId`); + if (bankId) { + return bankId; + } + + // Strategy 4: Try other identification + bankId = this.getValueFromPath(txDtls, `${bankPath}.Othr.Id`); + if (bankId) { + return bankId; + } + + return ''; + } + private parseDate(dateStr: string): Date { // Parse ISO date format (YYYY-MM-DD) if (dateStr.length === 10 && dateStr.includes('-')) { diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts index 87943a2..8424afb 100644 --- a/src/tests/camtParser.test.ts +++ b/src/tests/camtParser.test.ts @@ -634,4 +634,98 @@ describe('CamtParser', () => { expect(transaction.additionalInformation).toBe(''); expect(transaction.bookingText).toBe(''); }); + + it('should handle party structure variations in XML (Dbtr.Pty.Nm format)', () => { + const camtXml = ` + + + + party-structure-test + + + DE06940594210000027227 + + + + PRCD + 1000.00 + CRDT +
2013-10-31
+
+ + CLBD + 1200.00 + CRDT +
2013-11-01
+
+ + 200.00 + CRDT +
2013-11-01
+
2013-11-01
+ TXN003 + + + + PARTY-TEST-123 + + + Payment with party structure + + + + + John Smith Bank Format + + + + + DE12345678901234567890 + + + + + Jane Smith Bank Format + + + + + DE12345678901234567891 + + + + + + + BANKABC1XXX + + + + + BANKDEF2XXX + + + + + +
+
+
+
`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + expect(statements[0].transactions).toHaveLength(1); + + const transaction = statements[0].transactions[0]; + + // Verify that the party name is correctly extracted from Dbtr.Pty.Nm structure + expect(transaction.remoteName).toBe('John Smith Bank Format'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567890'); + expect(transaction.remoteBankId).toBe('BANKABC1XXX'); // Credit transaction uses DbtrAgt BIC + expect(transaction.purpose).toBe('Payment with party structure'); + expect(transaction.amount).toBe(200.0); + }); }); From 933c11638f3f07211589e8ef27a5df4ca543338d Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Mon, 22 Dec 2025 17:03:02 +0100 Subject: [PATCH 6/6] Update README.md --- README.md | 83 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b2ac828..9df2ea4 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,14 @@ In theory the library is compatible with a browser environment, but communicatin ### Installing -Installation is straight forward by simply adding the npm package. The package has no further dependencies on other packages. +Installation is straight forward by simply adding the npm package. ``` npm i lib-fints ``` +**Dependencies**: The library includes the `fast-xml-parser` package for robust CAMT statement parsing but has no other runtime dependencies. + ### Sample Usage The main public API of this library is the `FinTSClient` class and `FinTSConfig` class. In order to instantiate the client you need to provide a configuration instance. There are basically two ways to initialize a configuration object, one is when you communicate with a bank for the first time and the other when you already have banking information from a prevous session available (more on that later). @@ -59,10 +61,10 @@ If the call is successfull the response will contain a `bankingInformation` obje ```typescript export type BankingInformation = { - systemId: string; - bpd?: BPD; - upd?: UPD; - bankMessages: BankMessage[]; + systemId: string; + bpd?: BPD; + upd?: UPD; + bankMessages: BankMessage[]; }; ``` @@ -104,22 +106,22 @@ Most transactions may require authorization with a two step TAN process. As ment ```typescript // we use the node readline interface later to ask the user for a TAN const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, + input: process.stdin, + output: process.stdout, }); let response = await client.getAccountStatements(account.accountNumber); if (!response.success) { - return; + return; } // need to check if a TAN is required to continue the transaction if (response.requiresTan) { - // asking the user for the TAN, using the tanChallenge property - const tan = await rl.question(response.tanChallenge + ': '); - // continue the transaction by providing the tanReference from the response and the entered TAN - response = await client.getAccountStatementsWithTan(response.tanReference!, tan); + // asking the user for the TAN, using the tanChallenge property + const tan = await rl.question(response.tanChallenge + ': '); + // continue the transaction by providing the tanReference from the response and the entered TAN + response = await client.getAccountStatementsWithTan(response.tanReference!, tan); } ``` @@ -140,13 +142,13 @@ This not only saves you from making the same synchronization requests every time ```typescript const config = FinTSConfig.fromBankingInformation( - productId, - productVersion, - bankingInformation, - userId, - pin, - tanMethodId, - tanMediaName // when also needed (see below) + productId, + productVersion, + bankingInformation, + userId, + pin, + tanMethodId, + tanMediaName // when also needed (see below) ); const client = new FinTSClient(config); ``` @@ -161,12 +163,12 @@ You can get a list of all available TAN methods from the `config.availableTanMet ```typescript export type TanMethod = { - id: number; - name: string; - version: number; - activeTanMediaCount: number; - activeTanMedia: string[]; - tanMediaRequirement: TanMediaRequirement; + id: number; + name: string; + version: number; + activeTanMediaCount: number; + activeTanMedia: string[]; + tanMediaRequirement: TanMediaRequirement; }; ``` @@ -192,26 +194,26 @@ This will print out all sent messages and received responses to the console in a The following table shows all transactions supported by the FinTSClient interface: -| Transaction | Method | Description | FinTS Segment(s) | TAN Support | Account-Specific | -| -------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------- | ----------- | ---------------- | -| **Synchronization** | `synchronize()` | Synchronizes bank and account information, updating config.bankingInformation | HKIDN, HKVVB, HKSYN, HKTAB | ✓ | ❌ | -| **Account Balance** | `getAccountBalance(accountNumber)` | Fetches the current balance for a specific account | HKSAL | ✓ | ✓ | -| **Account Statements** | `getAccountStatements(accountNumber, from?, to?)` | Fetches account transactions/statements for a date range | HKKAZ | ✓ | ✓ | -| **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 | ✓ | ✓ | -| **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 | - | ❌ | ❌ | +| Transaction | Method | Description | FinTS Segment(s) | TAN Support | Account-Specific | +| -------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------- | ----------- | ---------------- | +| **Synchronization** | `synchronize()` | Synchronizes bank and account information, updating config.bankingInformation | HKIDN, HKVVB, HKSYN, HKTAB | ✓ | ❌ | +| **Account Balance** | `getAccountBalance(accountNumber)` | Fetches the current balance for a specific account | HKSAL | ✓ | ✓ | +| **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 | ✓ | ✓ | +| **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 | - | ❌ | ❌ | ### Transaction Support Checking For each account-specific transaction, the client provides corresponding `can*` methods to check if the bank or specific account supports the transaction: -| Support Check Method | Purpose | -| -------------------------------------------- | ------------------------------------------------------ | -| `canGetAccountBalance(accountNumber?)` | Checks if account balance fetching is supported | -| `canGetAccountStatements(accountNumber?)` | Checks if account statements fetching is supported | -| `canGetPortfolio(accountNumber?)` | Checks if portfolio information fetching is supported | -| `canGetCreditCardStatements(accountNumber?)` | Checks if credit card statements fetching is supported | +| Support Check Method | Purpose | +| -------------------------------------------- | --------------------------------------------------------------- | +| `canGetAccountBalance(accountNumber?)` | Checks if account balance fetching is supported | +| `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 | ### TAN Continuation Methods @@ -242,6 +244,7 @@ Implementing further transactions should be straight forward and contributions a ## Built With - [Typescript](https://www.typescriptlang.org/) - Programming Language +- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) - XML parsing for CAMT statements - [Vitest](https://vitest.dev/) - Testing Framework - [pnpm](https://pnpm.io/) - Package manager