diff --git a/src/app-gocardless/bank-factory.js b/src/app-gocardless/bank-factory.js index 81afcde6a..f0c30b625 100644 --- a/src/app-gocardless/bank-factory.js +++ b/src/app-gocardless/bank-factory.js @@ -9,6 +9,7 @@ import BnpBeGebabebb from './banks/bnp-be-gebabebb.js'; import DanskeBankDabNO22 from './banks/danskebank-dabno22.js'; import SparNordSpNoDK22 from './banks/sparnord-spnodk22.js'; import Belfius from './banks/belfius_gkccbebb.js'; +import SpkMarburgBiedenkopfHeladef1mar from './banks/spk-marburg-biedenkopf-heladef1mar.js'; const banks = [ AmericanExpressAesudef1, @@ -21,6 +22,7 @@ const banks = [ DanskeBankDabNO22, SparNordSpNoDK22, Belfius, + SpkMarburgBiedenkopfHeladef1mar, ]; export default (institutionId) => diff --git a/src/app-gocardless/banks/spk-marburg-biedenkopf-heladef1mar.js b/src/app-gocardless/banks/spk-marburg-biedenkopf-heladef1mar.js new file mode 100644 index 000000000..b582a59a2 --- /dev/null +++ b/src/app-gocardless/banks/spk-marburg-biedenkopf-heladef1mar.js @@ -0,0 +1,87 @@ +import { + printIban, + amountToInteger, + sortByBookingDateOrValueDate, +} from '../utils.js'; +import d from 'date-fns'; + +const SORTED_BALANCE_TYPE_LIST = [ + 'closingBooked', + 'expected', + 'forwardAvailable', + 'interimAvailable', + 'interimBooked', + 'nonInvoiced', + 'openingBooked', +]; + +/** @type {import('./bank.interface.js').IBank} */ +export default { + institutionIds: ['SPK_MARBURG_BIEDENKOPF_HELADEF1MAR'], + + normalizeAccount(account) { + return { + account_id: account.id, + institution: account.institution, + mask: (account?.iban || '0000').slice(-4), + iban: account?.iban || null, + name: [account.product, printIban(account), account.currency] + .filter(Boolean) + .join(' '), + official_name: account.product, + type: 'checking', + }; + }, + + normalizeTransaction(transaction, _booked) { + const date = + transaction.bookingDate || + transaction.bookingDateTime || + transaction.valueDate || + transaction.valueDateTime; + + // If we couldn't find a valid date field we filter out this transaction + // and hope that we will import it again once the bank has processed the + // transaction further. + if (!date) { + return null; + } + + let remittanceInformationUnstructured; + + if (transaction.remittanceInformationUnstructured) { + remittanceInformationUnstructured = + transaction.remittanceInformationUnstructured; + } else if (transaction.remittanceInformationStructured) { + remittanceInformationUnstructured = + transaction.remittanceInformationStructured; + } else if (transaction.remittanceInformationStructuredArray?.length > 0) { + remittanceInformationUnstructured = + transaction.remittanceInformationStructuredArray?.join(' '); + } + + return { + ...transaction, + date: d.format(d.parseISO(date), 'yyyy-MM-dd'), + remittanceInformationUnstructured: remittanceInformationUnstructured, + }; + }, + + sortTransactions(transactions = []) { + return sortByBookingDateOrValueDate(transactions); + }, + + calculateStartingBalance(sortedTransactions = [], balances = []) { + const currentBalance = balances + .filter((item) => SORTED_BALANCE_TYPE_LIST.includes(item.balanceType)) + .sort( + (a, b) => + SORTED_BALANCE_TYPE_LIST.indexOf(a.balanceType) - + SORTED_BALANCE_TYPE_LIST.indexOf(b.balanceType), + )[0]; + + return sortedTransactions.reduce((total, trans) => { + return total - amountToInteger(trans.transactionAmount.amount); + }, amountToInteger(currentBalance?.balanceAmount?.amount || 0)); + }, +}; diff --git a/src/app-gocardless/banks/tests/spk-marburg-biedenkopf-heladef1mar.spec.js b/src/app-gocardless/banks/tests/spk-marburg-biedenkopf-heladef1mar.spec.js new file mode 100644 index 000000000..5641400b2 --- /dev/null +++ b/src/app-gocardless/banks/tests/spk-marburg-biedenkopf-heladef1mar.spec.js @@ -0,0 +1,254 @@ +import SpkMarburgBiedenkopfHeladef1mar from '../spk-marburg-biedenkopf-heladef1mar.js'; + +describe('SpkMarburgBiedenkopfHeladef1mar', () => { + describe('#normalizeAccount', () => { + /** @type {import('../../gocardless.types.js').DetailedAccountWithInstitution} */ + const accountRaw = { + resourceId: 'e896eec6-6096-4efc-a941-756bd9d74765', + iban: 'DE50533500000123456789', + currency: 'EUR', + ownerName: 'JANE DOE', + product: 'Sichteinlagen', + bic: 'HELADEF1MAR', + usage: 'PRIV', + id: 'a787ba27-02ee-4fd6-be86-73831adc5498', + created: '2024-01-01T14:17:11.630352Z', + last_accessed: '2024-01-01T14:19:42.709478Z', + institution_id: 'SPK_MARBURG_BIEDENKOPF_HELADEF1MAR', + status: 'READY', + owner_name: 'JANE DOE', + institution: { + id: 'SPK_MARBURG_BIEDENKOPF_HELADEF1MAR', + name: 'Sparkasse Marburg-Biedenkopf', + bic: 'HELADEF1MAR', + transaction_total_days: '360', + countries: ['DE'], + logo: 'https://storage.googleapis.com/gc-prd-institution_icons-production/DE/PNG/sparkasse.png', + supported_payments: { + 'single-payment': ['SCT', 'ISCT'], + }, + supported_features: [ + 'card_accounts', + 'payments', + 'pending_transactions', + ], + /*"identification_codes": []*/ + }, + }; + + it('returns normalized account data returned to Frontend', () => { + expect( + SpkMarburgBiedenkopfHeladef1mar.normalizeAccount(accountRaw), + ).toEqual({ + account_id: 'a787ba27-02ee-4fd6-be86-73831adc5498', + iban: 'DE50533500000123456789', + institution: { + bic: 'HELADEF1MAR', + countries: ['DE'], + id: 'SPK_MARBURG_BIEDENKOPF_HELADEF1MAR', + logo: 'https://storage.googleapis.com/gc-prd-institution_icons-production/DE/PNG/sparkasse.png', + name: 'Sparkasse Marburg-Biedenkopf', + supported_features: [ + 'card_accounts', + 'payments', + 'pending_transactions', + ], + supported_payments: { + 'single-payment': ['SCT', 'ISCT'], + }, + transaction_total_days: '360', + }, + mask: '6789', + name: 'Sichteinlagen (XXX 6789) EUR', + official_name: 'Sichteinlagen', + type: 'checking', + }); + }); + }); + + const transactionsRaw = [ + { + transactionId: 'fefa0b605ac14a7eb14f4c8ab6a6af55', + bookingDate: '2023-12-29', + valueDate: '2023-12-29', + transactionAmount: { + amount: '-40.00', + currency: 'EUR', + }, + creditorName: 'JET Tankstelle', + remittanceInformationStructured: 'AUTORISATION 28.12. 18:30', + proprietaryBankTransactionCode: 'NSTO+000+0000+000-AA', + internalTransactionId: '761660c052ed48e78c2be39775f08da9', + date: '2023-12-29', + }, + { + transactionId: '1a8e5d0df259472694f13132001af0a6', + bookingDate: '2023-12-28', + valueDate: '2023-12-28', + transactionAmount: { + amount: '-1242.47', + currency: 'EUR', + }, + creditorName: 'Peter Muster', + remittanceInformationStructured: 'Miete 12/2023', + proprietaryBankTransactionCode: 'NSTO+111+1111+111-BB', + internalTransactionId: '5a20ac78b146401e940b6fee30ee404b', + date: '2023-12-28', + }, + { + transactionId: '166983e65ec54000a361a952e6161f33', + bookingDate: '2023-12-27', + valueDate: '2023-12-27', + transactionAmount: { + amount: '1541.23', + currency: 'EUR', + }, + debtorName: 'Arbeitgeber AG', + remittanceInformationStructured: 'Lohn/Gehalt 12/2023', + proprietaryBankTransactionCode: 'NSTO+222+2222+222-CC', + internalTransactionId: '51630dda877f45f186d315b8058d891a', + date: '2023-12-27', + }, + { + transactionId: '4dd9f4c9968a45739c0705ebc675b54b', + bookingDate: '2023-12-26', + valueDate: '2023-12-26', + transactionAmount: { + amount: '-8.00', + currency: 'EUR', + }, + remittanceInformationStructuredArray: [ + 'Entgeltabrechnung', + 'siehe Anlage', + ], + proprietaryBankTransactionCode: 'NSTO+333+3333+333-DD', + internalTransactionId: '9c58c87c2d1644e4a5e149c837c16bbb', + date: '2023-12-26', + }, + ]; + + describe('#normalizeTransaction', () => { + it('fallbacks to remittanceInformationStructured when remittanceInformationUnstructed is not set', () => { + const transaction = { + transactionId: 'fefa0b605ac14a7eb14f4c8ab6a6af55', + bookingDate: '2023-12-29', + valueDate: '2023-12-29', + transactionAmount: { + amount: '-40.00', + currency: 'EUR', + }, + creditorName: 'JET Tankstelle', + remittanceInformationStructured: 'AUTORISATION 28.12. 18:30', + proprietaryBankTransactionCode: 'NSTO+000+0000+000-AA', + internalTransactionId: '761660c052ed48e78c2be39775f08da9', + date: '2023-12-29', + }; + + expect( + SpkMarburgBiedenkopfHeladef1mar.normalizeTransaction(transaction, true) + .remittanceInformationUnstructured, + ).toEqual('AUTORISATION 28.12. 18:30'); + }); + + it('fallbacks to remittanceInformationStructuredArray when remittanceInformationUnstructed and remittanceInformationStructured is not set', () => { + const transaction = { + transactionId: '4dd9f4c9968a45739c0705ebc675b54b', + bookingDate: '2023-12-26', + valueDate: '2023-12-26', + transactionAmount: { + amount: '-8.00', + currency: 'EUR', + }, + remittanceInformationStructuredArray: [ + 'Entgeltabrechnung', + 'siehe Anlage', + ], + proprietaryBankTransactionCode: 'NSTO+333+3333+333-DD', + internalTransactionId: '9c58c87c2d1644e4a5e149c837c16bbb', + date: '2023-12-26', + }; + + expect( + SpkMarburgBiedenkopfHeladef1mar.normalizeTransaction(transaction, true) + .remittanceInformationUnstructured, + ).toEqual('Entgeltabrechnung siehe Anlage'); + }); + }); + + describe('#sortTransactions', () => { + it('handles empty arrays', () => { + const transactions = []; + const sortedTransactions = + SpkMarburgBiedenkopfHeladef1mar.sortTransactions(transactions); + expect(sortedTransactions).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + const sortedTransactions = + SpkMarburgBiedenkopfHeladef1mar.sortTransactions(undefined); + expect(sortedTransactions).toEqual([]); + }); + + it('returns sorted array for unsorted inputs', () => { + const normalizeTransactions = transactionsRaw.map((tx) => + SpkMarburgBiedenkopfHeladef1mar.normalizeTransaction(tx, true), + ); + const originalOrder = Array.from(normalizeTransactions); + const swap = (a, b) => { + const swap = normalizeTransactions[a]; + normalizeTransactions[a] = normalizeTransactions[b]; + normalizeTransactions[b] = swap; + }; + swap(1, 4); + swap(3, 6); + swap(0, 7); + const sortedTransactions = + SpkMarburgBiedenkopfHeladef1mar.sortTransactions(normalizeTransactions); + expect(sortedTransactions).toEqual(originalOrder); + }); + }); + + describe('#countStartingBalance', () => { + /** @type {import('../../gocardless-node.types.js').Balance[]} */ + const balances = [ + { + balanceAmount: { amount: '3596.87', currency: 'EUR' }, + balanceType: 'closingBooked', + referenceDate: '2023-12-29', + }, + ]; + + it('should return 0 when no transactions or balances are provided', () => { + const startingBalance = + SpkMarburgBiedenkopfHeladef1mar.calculateStartingBalance([], []); + expect(startingBalance).toEqual(0); + }); + + it('should calculate the starting balance correctly', () => { + const normalizeTransactions = transactionsRaw.map((tx) => + SpkMarburgBiedenkopfHeladef1mar.normalizeTransaction(tx, true), + ); + const sortedTransactions = + SpkMarburgBiedenkopfHeladef1mar.sortTransactions(normalizeTransactions); + + const startingBalance = + SpkMarburgBiedenkopfHeladef1mar.calculateStartingBalance( + sortedTransactions, + balances, + ); + + expect(startingBalance).toEqual(334611); + }); + + it('returns the same balance amount when no transactions', () => { + const transactions = []; + + expect( + SpkMarburgBiedenkopfHeladef1mar.calculateStartingBalance( + transactions, + balances, + ), + ).toEqual(359687); + }); + }); +}); diff --git a/src/app-gocardless/gocardless-node.types.ts b/src/app-gocardless/gocardless-node.types.ts index 786d0d126..a4c2bc7bb 100644 --- a/src/app-gocardless/gocardless-node.types.ts +++ b/src/app-gocardless/gocardless-node.types.ts @@ -435,6 +435,11 @@ export type Transaction = { */ remittanceInformationStructured?: string; + /** + * Reference as contained in the structured remittance array reference structure + */ + remittanceInformationStructuredArray?: string[]; + /** * Reference as contained in the unstructured remittance reference structure */ diff --git a/src/app-gocardless/tests/bank-factory.spec.js b/src/app-gocardless/tests/bank-factory.spec.js index 93484b225..5dc50bc8f 100644 --- a/src/app-gocardless/tests/bank-factory.spec.js +++ b/src/app-gocardless/tests/bank-factory.spec.js @@ -4,6 +4,7 @@ import SandboxfinanceSfin0000 from '../banks/sandboxfinance-sfin0000.js'; import IngPlIngbplpw from '../banks/ing-pl-ingbplpw.js'; import IntegrationBank from '../banks/integration-bank.js'; import Belfius from '../banks/belfius_gkccbebb.js'; +import SpkMarburgBiedenkopfHeladef1mar from '../banks/spk-marburg-biedenkopf-heladef1mar.js'; describe('BankFactory', () => { it('should return MbankRetailBrexplpw when institutionId is mbank-retail-brexplpw', () => { @@ -27,6 +28,13 @@ describe('BankFactory', () => { expect(result.institutionIds).toContain(institutionId); }); + it('should return SpkMarburgBiedenkopfHeladef1mar when institutionId is SPK_MARBURG_BIEDENKOPF_HELADEF1MAR', () => { + const institutionId = SpkMarburgBiedenkopfHeladef1mar.institutionIds[0]; + const result = BankFactory(institutionId); + + expect(result.institutionIds).toContain(institutionId); + }); + it('should return Belfius when institutionId is BELFIUS_GKCCBEBB', () => { const institutionId = Belfius.institutionIds[0]; const result = BankFactory(institutionId); diff --git a/upcoming-release-notes/297.md b/upcoming-release-notes/297.md new file mode 100644 index 000000000..f2fdc76e7 --- /dev/null +++ b/upcoming-release-notes/297.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [visurel] +--- + +Add GoCardless bank integration for Sparkasse Marburg-Biedenkopf (Germany).