Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/plugins/bunq/__tests__/converters/accounts/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { convertAccount } from '../../../converters'
import { AccountData, BunqAccountType } from '../../../models'
import { AccountOrCard, AccountType } from '../../../../../types/zenmoney'

const bunqAccount: AccountData = {
type: BunqAccountType.Bank,
id: 3782,
description: 'Account name',
currency: 'EUR',
balance: { value: '222.12' },
alias: [
{ type: 'IBAN', value: '333444555', name: 'Alias name' }
],
status: 'ACTIVE'
}

const convertedAccount: AccountOrCard = {
type: AccountType.checking,
title: 'Account name',
instrument: 'EUR',
balance: 222.12,
syncIds: ['3782', '333444555'],
id: '3782'
}

describe('convertAccounts', () => {
it('converts current account', () => {
expect(convertAccount(bunqAccount)).toEqual(convertedAccount)
})
})
53 changes: 53 additions & 0 deletions src/plugins/bunq/__tests__/converters/transactions/outcome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { BunqPayment } from '../../../models'
import { Transaction } from '../../../../../types/zenmoney'
import { convertTransactions } from '../../../converters'

const bunqTransaction: BunqPayment = {
id: 1212,
created: '2024-05-02 16:14:44.108574',
monetary_account_id: 444,
amount: {
currency: 'EUR',
value: '-24.78'
},
description: 'FORU ROTTERDAM, NL',
type: 'MASTERCARD',
merchant_reference: null,
counterparty_alias: {
iban: null,
display_name: 'ROTTERDAM FORU',
merchant_category_code: '5651'
},
sub_type: 'PAYMENT'
}

const formattedTransaction: Transaction = {
date: new Date(Date.UTC(2024, 4, 2, 16, 14, 44, 108)),
movements: [
{
id: '1212',
sum: -24.78,
account: {
id: '444'
},
fee: 0,
invoice: null
}
],
merchant: {
title: 'ROTTERDAM FORU',
mcc: 5651,
category: undefined,
location: null,
city: null,
country: null
},
comment: 'FORU ROTTERDAM, NL',
hold: false
}

describe('convertTransaction', () => {
it('should convert transaction', () => {
expect(convertTransactions([bunqTransaction], [])).toEqual([formattedTransaction])
})
})
128 changes: 128 additions & 0 deletions src/plugins/bunq/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
AccountData,
BunqAccountType,
BunqAccountResponse,
BunqPaymentListResponse,
BunqPayment,
DateTimeString,
InstallationContext,
Preferences,
SessionContext
} from './models'
import { fetchApiInstallation, fetchDeviceSet, fetchMonetaryAccountList, fetchPayments, fetchSessionStart } from './fetchApi'
import { generatePemCerts, limitRequests } from './utils'
import { isString, last } from 'lodash'

export async function registerDevice (preferences: Preferences): Promise<InstallationContext> {
const installationContext = await installationApi()

await deviceSetApi(installationContext, preferences)

return installationContext
}

export async function sessionStartApi (installationContext: InstallationContext, preferences: Preferences): Promise<SessionContext> {
return await fetchSessionStart(installationContext.installationToken, installationContext.clientPrivateKey, preferences.apiKey).then((data) => (
{
sessionToken: data.Response[1].Token.token,
userId: data.Response[2].UserPerson.id
}))
}

export async function getAccountsList (sessionContext: SessionContext): Promise<AccountData[]> {
const accounts: AccountData[] = []
const result = await fetchMonetaryAccountList(sessionContext.userId, sessionContext.sessionToken)
const olderIdPointer = 'older_id='
const timer = limitRequests(3, 3000)

await timer()

const mapAccounts = (accounts: BunqAccountResponse): AccountData[] => accounts.map((account) => {
const monetaryAccount = Object.values(account)[0]
const monetaryType = Object.keys(account)[0]
const type = ((): BunqAccountType => {
switch (monetaryType) {
case 'MonetaryAccountInvestment':
return BunqAccountType.Investment
default:
return BunqAccountType.Bank
}
})()

const accountData: AccountData = { ...monetaryAccount, type }

return accountData
}).filter((account) => (account.status === 'ACTIVE') &&
(account.type !== BunqAccountType.Investment)) // we don't sync investment accounts, because they have no proper balance and transactions

accounts.push(...mapAccounts(result.Response))

let olderUrl = result.Pagination.older_url
while (isString(olderUrl)) {
const olderIdPos = olderUrl.lastIndexOf(olderIdPointer)
const olderId = Number(olderUrl.substring(olderIdPos + olderIdPointer.length))

await timer()
const newResult = await fetchMonetaryAccountList(sessionContext.userId, sessionContext.sessionToken, olderId)
olderUrl = newResult.Pagination.older_url
accounts.push(...mapAccounts(newResult.Response))
}

return accounts
}

export async function getTransactionsForAccounts (accounts: AccountData[], sessionContext: SessionContext, dateFrom: DateTimeString): Promise<BunqPayment[]> {
const transactions: BunqPayment[] = []

for (const account of accounts) {
transactions.push(...await getTransactionsApi(account.id, sessionContext, dateFrom))
}

return transactions
}

async function getTransactionsApi (accountId: number, sessionContext: SessionContext, dateFrom: DateTimeString): Promise<BunqPayment[]> {
const transactions: BunqPayment[] = []

const result = await fetchPayments(sessionContext.userId, accountId, sessionContext.sessionToken)
const olderIdPointer = 'older_id='
const timer = limitRequests(3, 3000)

await timer()

const mapTransactions = (payments: BunqPaymentListResponse): BunqPayment[] => payments.filter((payment) => {
const transaction: BunqPayment = payment.Payment

return transaction.created >= dateFrom
}).map((payment) => payment.Payment)

transactions.push(...mapTransactions(result.Response))

let olderUrl = result.Pagination.older_url
let lastReceivedDate = last(transactions)?.created ?? ''
while (isString(olderUrl) && lastReceivedDate >= dateFrom) {
const olderIdPos = olderUrl.lastIndexOf(olderIdPointer)
const olderId = Number(olderUrl.substring(olderIdPos + olderIdPointer.length))

await timer()
const newResult = await fetchPayments(sessionContext.userId, accountId, sessionContext.sessionToken, olderId)
olderUrl = newResult.Pagination.older_url
const newTransactions = mapTransactions(newResult.Response)
transactions.push(...newTransactions)
lastReceivedDate = last(newTransactions)?.created ?? ''
}

return transactions
}

async function installationApi (): Promise<InstallationContext> {
const certs = await generatePemCerts()
return await fetchApiInstallation(certs.publicKey).then((data) => ({
clientPrivateKey: certs.privateKey,
installationToken: data.Response[1].Token.token
}))
}

async function deviceSetApi (installationContext: InstallationContext, preferences: Preferences): Promise<void> {
return await fetchDeviceSet(installationContext.installationToken, preferences.apiKey)
}
128 changes: 128 additions & 0 deletions src/plugins/bunq/converters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { AccountOrCard, AccountReferenceById, AccountType, Merchant, Movement, Transaction } from '../../types/zenmoney'
import { AccountData, BunqPayment, DateTimeString } from './models'
import { flatten, isNull, isObject, isString } from 'lodash'

export function convertAccount (apiAccount: AccountData): AccountOrCard {
return {
id: String(apiAccount.id),
balance: Number(apiAccount.balance.value),
syncIds: [String(apiAccount.id), ...apiAccount.alias.map((alias) => alias.value)],
instrument: apiAccount.currency,
title: apiAccount.description,
type: AccountType.checking
}
}

export function convertTransactions (apiTransactions: BunqPayment[], accounts: AccountData[]): Transaction[] {
const accountAliases: Array<[string, AccountData]> = flatten(accounts.map(
account => account.alias.map<[string, AccountData]>(alias => ([alias.value, account]))))
const accountsMap: Map<string, AccountData> = new Map(accountAliases)

const transactions: Transaction[] = apiTransactions.map((transaction) => convertTransaction(transaction, accountsMap))

return mergeTransfers(transactions)
}

export function convertTransaction (apiTransaction: BunqPayment, ownAccounts: Map<string, AccountData>): Transaction {
const id = String(apiTransaction.id)
const accountId = String(apiTransaction.monetary_account_id)
const description = apiTransaction.description
const mmc = apiTransaction.counterparty_alias.merchant_category_code
const reference = apiTransaction.merchant_reference
const counterIban = apiTransaction.counterparty_alias.iban
const mainMovement: Movement = {
id,
account: { id: accountId },
invoice: null,
sum: Number(apiTransaction.amount.value),
fee: 0
}

const isSepaTransfer = apiTransaction.type === 'EBA_SCT' && apiTransaction.sub_type === 'SCT'
const isMastercardPayment = apiTransaction.type === 'MASTERCARD' && apiTransaction.sub_type === 'PAYMENT'
const isIgnoreTypeName = isSepaTransfer || isMastercardPayment
const merchantName = isIgnoreTypeName ? apiTransaction.counterparty_alias.display_name : `${apiTransaction.type} ${apiTransaction.sub_type}: ${apiTransaction.counterparty_alias.display_name}`
const merchant: Merchant = {
title: merchantName,
mcc: isString(mmc) ? Number(mmc) : null,
category: reference ?? undefined,
location: null,
city: null,
country: null
}

const ownCounter = !isNull(counterIban) && ownAccounts.get(counterIban)
const isOwnCounter = isObject(ownCounter)

const alterMovement: Movement = {
id: null,
fee: 0,
sum: -Number(apiTransaction.amount.value),
invoice: null,
account: { id: (isOwnCounter && String(ownCounter.id)) || '' }
}

const movementsWithAlter: [Movement, Movement] = [mainMovement, alterMovement]
return {
hold: false,
date: formatDate(apiTransaction.created),
movements: isOwnCounter ? movementsWithAlter : [mainMovement],
merchant: isOwnCounter ? null : merchant,
comment: description
}
}

function mergeTransfers (transactions: Transaction[]): Transaction[] {
const filteredTransactions: Transaction[] = []
const transfersHash: {[accountId: string]: {[sumValue: string]: {[timeStamp: string]: Movement | undefined}}} = {}

for (const transaction of transactions) {
const isTransfer = transaction.movements.length === 2

if (!isTransfer) {
filteredTransactions.push(transaction)
continue
}

const mainMovement = transaction.movements[0]
const mainMovementAccountId = (mainMovement.account as AccountReferenceById).id
const mainSumString = String(mainMovement.sum)
const mainDateString = transaction.date.toLocaleDateString() + transaction.date.toLocaleTimeString()

const isSaved = isObject(transfersHash[mainMovementAccountId]?.[mainSumString]?.[mainDateString])

if (isSaved) {
transfersHash[mainMovementAccountId][mainSumString][mainDateString] = undefined
continue
}

filteredTransactions.push(transaction)

const altMovement = transaction.movements[1] as Movement
const altAccountId = (altMovement.account as AccountReferenceById).id
const altSumString = String(altMovement.sum)
const altDateString = transaction.date.toLocaleDateString() + transaction.date.toLocaleTimeString()

transfersHash[altAccountId] = transfersHash[altAccountId] ?? {}
transfersHash[altAccountId][altSumString] = transfersHash[altAccountId][altSumString] ?? {}
transfersHash[altAccountId][altSumString][altDateString] = altMovement
}

return filteredTransactions
}

// We need to behave dates as they are given in UTC timestamp
function formatDate (dateString: DateTimeString): Date {
const localDate = new Date(dateString)
const utcDate = Date.UTC(
localDate.getFullYear(),
localDate.getMonth(),
localDate.getDate(),
localDate.getHours(),
localDate.getMinutes(),
localDate.getSeconds(),
localDate.getMilliseconds()
)

return new Date(utcDate)
}
Loading