Add method to parse bank transactions as JSON

This implements parsing of the Account Information Service format as
defined in the Berlin Group Group NextGenPSD2 XS2A Framework, which
is widely used across various European banks.

This is a first step to replace the current bank import features with
a standardized JSON interface.
This commit is contained in:
Patrick Gansterer 2021-02-05 12:19:05 +01:00
parent 226c11737f
commit 4752a0aaa9
3 changed files with 471 additions and 0 deletions

View File

@ -24,4 +24,8 @@ class BankAccount < ApplicationRecord
end
count
end
def last_transaction_date
bank_transactions.order(date: :desc).first&.date
end
end

View File

@ -0,0 +1,55 @@
class BankAccountInformationImporter
def initialize(bank_account)
@bank_account = bank_account
end
def import!(content)
return nil if content.empty?
data = JSON.parse content, symbolize_names: true
return 0 if data.empty?
booked = data.fetch(:transactions, {}).fetch(:booked, [])
ret = 0
booked.each do |t|
amount = parse_account_information_amount t[:transactionAmount]
entityName = amount < 0 ? t[:creditorName] : t[:debtorName]
entityAccount = amount < 0 ? t[:creditorAccount] : t[:debtorAccount]
@bank_account.bank_transactions.where(external_id: t[:transactionId]).first_or_create.update({
date: t[:bookingDate],
amount: amount,
iban: entityAccount && entityAccount[:iban],
reference: t[:remittanceInformationUnstructured],
text: entityName,
receipt: t[:additionalInformation],
})
ret += 1
end
balances = Hash[ data[:balances] ? data[:balances].map { |b| [b[:balanceType], b[:balanceAmount]] } : [] ]
balance = balances.values.first
%w(closingBooked expected authorised openingBooked interimAvailable forwardAvailable nonInvoiced).each do |type|
value = balances[type]
if value then
balance = value
break
end
end
@bank_account.balance = parse_account_information_amount(balance) || @bank_account.bank_transactions.sum(:amount)
@bank_account.import_continuation_point = booked.first&.fetch(:entryReference, nil)
@bank_account.last_import = Time.now
@bank_account.save!
ret
end
private
def parse_account_information_amount(value)
value && value[:amount].to_f
end
end

View File

@ -0,0 +1,412 @@
require_relative '../spec_helper'
describe BankTransaction do
let(:bank_account) { create :bank_account }
it 'empty content' do
content = <<-JSON
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to be(nil)
end
it 'invalid JSON' do
content = <<-JSON
#invalid#
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect{importer.import!(content)}.to raise_error(JSON::ParserError)
end
it 'empty object' do
content = <<-JSON
{}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(0)
end
it 'des sometet' do
content = <<-JSON
{
"balances": [],
"transactions": {}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(0)
end
it 'without actual content' do
content = <<-JSON
{
"balances": [],
"transactions": {
"booked": []
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(0)
end
it 'use favorite balanceType' do
content = <<-JSON
{
"balances": [
{
"balanceType": "authorised",
"balanceAmount": {
"currency": "EUR",
"amount": "123.45"
}
},
{
"balanceType": "closingBooked",
"balanceAmount": {
"currency": "EUR",
"amount": "234.56"
}
},
{
"balanceType": "##UNKNOWN##",
"balanceAmount": {
"currency": "EUR",
"amount": "345.67"
}
},
{
"balanceType": "expected",
"balanceAmount": {
"currency": "EUR",
"amount": "456.78"
}
}
]
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(0)
expect(bank_account.balance).to eq(234.56)
end
it 'use unknown balance if no other exists' do
content = <<-JSON
{
"balances": [
{
"balanceType": "##UNKNOWN##",
"balanceAmount": {
"currency": "EUR",
"amount": "123.45"
}
}
]
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(0)
expect(bank_account.balance).to eq(123.45)
end
it 'use transaction sum as balance' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionId": "1",
"transactionAmount": {
"currency": "EUR",
"amount": "12.3"
},
"bookingDate": "2019-02-14",
"valueDate": "2019-02-13",
"debtorName": "Example User"
},
{
"transactionId": "2",
"transactionAmount": {
"currency": "EUR",
"amount": "-1.2"
},
"bookingDate": "2019-02-12",
"valueDate": "2019-02-11",
"debtorName": "Example Supplier"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(2)
expect(bank_account.last_transaction_date).to eq('2019-02-14'.to_date)
expect(bank_account.balance).to eq(11.1)
end
it 'can import debit entry' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionAmount": {
"currency": "EUR",
"amount": "-194.83"
},
"creditorAccount": {
"iban": "DE72957284895783674747"
},
"creditorName": "Deutsche Bundesbahn",
"creditorId": "DE76356347538353",
"mandateId": "34564OB3633ZT3",
"remittanceInformationUnstructured": "743574386368 Muenchen-Hamburg 27.03.2019",
"bookingDate": "2019-02-13",
"valueDate": "2019-02-13",
"entryReference": "3648793450370305937",
"transactionId": "3648793450370305937",
"bankTransactionCode": "PMNT-RDDT-ESDD",
"additionalInformation": "Lastschrift"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(1)
bt = bank_account.bank_transactions.first
expect(bt.amount).to eq(-194.83)
expect(bt.date).to eq('2019-02-13'.to_date)
expect(bt.text).to eq('Deutsche Bundesbahn')
expect(bt.iban).to eq('DE72957284895783674747')
expect(bt.reference).to eq("743574386368 Muenchen-Hamburg 27.03.2019")
expect(bt.receipt).to eq('Lastschrift')
end
it 'can import US bank transfer' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionAmount": {
"currency": "EUR",
"amount": "-238.68"
},
"originalAmount": {
"currency": "USD",
"amount": "-270.46"
},
"currencyExchange": {
"sourceCurrency": "EUR",
"targetCurrency": "USD",
"unitCurrency": "EUR",
"quotationDate": "2019-02-13",
"exchangeRate": "1.13315"
},
"creditorAccount": {
"bban": "693757683985"
},
"creditorAgent": "FRTZUSWA435",
"creditorName": "Hammersmith Inc.",
"creditorAddress": "1326 Canwood Drive, CA 45562, US",
"remittanceInformationUnstructured": "Martin Schöneicher, Inv# 123453423, Thx",
"endToEndId": "Corvette Ersatzteile",
"bookingDate": "2019-02-13",
"valueDate": "2019-02-13",
"entryReference": "8463794476737676345",
"transactionId": "8463794476737676345",
"bankTransactionCode": "PMNT-ICDT-XBCT",
"additionalInformation": "Auslands-Überweisung"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(1)
bt = bank_account.bank_transactions.first
expect(bt.amount).to eq(-238.68)
expect(bt.date).to eq('2019-02-13'.to_date)
expect(bt.text).to eq('Hammersmith Inc.')
expect(bt.iban).to be(nil)
expect(bt.reference).to eq('Martin Schöneicher, Inv# 123453423, Thx')
expect(bt.receipt).to eq('Auslands-Überweisung')
end
it 'can import bank fees' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionAmount": {
"currency": "EUR",
"amount": "-12.3"
},
"creditorName": "superbank AG",
"remittanceInformationUnstructured": "Überweisung US, Wechselspesen u Provision",
"bookingDate": "2019-02-14",
"valueDate": "2019-02-13",
"entryReference": "3346453823263457367",
"transactionId": "3346453823263457367",
"bankTransactionCode": "ACMT-MCOP-CHRG",
"additionalInformation": "Spesen/Gebühren"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(1)
bt = bank_account.bank_transactions.first
expect(bt.amount).to eq(-12.3)
expect(bt.date).to eq('2019-02-14'.to_date)
expect(bt.text).to eq('superbank AG')
expect(bt.iban).to be(nil)
expect(bt.reference).to eq("Überweisung US, Wechselspesen u Provision")
expect(bt.receipt).to eq('Spesen/Gebühren')
end
it 'can import credit entry' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionAmount": {
"currency": "EUR",
"amount": "136.47"
},
"debtorAccount": {
"iban": "AT251657674147449499"
},
"debtorName": "Maria Reithuber",
"remittanceInformationUnstructured": "Danke für's Auslegen",
"endToEndId": "Auslage von Martin S.",
"bookingDate": "2019-02-14",
"valueDate": "2019-02-14",
"entryReference": "4856465768967584736",
"transactionId": "4856465768967584736",
"bankTransactionCode": "PMNT-RCDT-ESCT",
"additionalInformation": "Gutschrift"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(1)
bt = bank_account.bank_transactions.first
expect(bt.amount).to eq(136.47)
expect(bt.date).to eq('2019-02-14'.to_date)
expect(bt.text).to eq('Maria Reithuber')
expect(bt.iban).to eq('AT251657674147449499')
expect(bt.reference).to eq("Danke für's Auslegen")
expect(bt.receipt).to eq('Gutschrift')
end
it 'use transaction sum as balance' do
content = <<-JSON
{
"transactions": {
"booked": [
{
"transactionId": "T1",
"entryReference": "E1",
"bookingDate": "2020-01-01",
"valueDate": "2020-01-02",
"transactionAmount": {
"currency": "EUR",
"amount": "11"
},
"creditorName": "CN1",
"creditorAccount": {
"iban": "CH9300762011623852957"
},
"debtorName": "DN1",
"debtorAccount": {
"iban": "DE72957284895783674747"
},
"additionalInformation": "AI1"
},
{
"transactionId": "T2",
"entryReference": "E2",
"bookingDate": "2010-02-01",
"valueDate": "2010-02-02",
"transactionAmount": {
"currency": "EUR",
"amount": "-22"
},
"creditorName": "CN2",
"creditorAccount": {
"iban": "CH9300762011623852957"
},
"debtorName": "DN2",
"debtorAccount": {
"iban": "DE72957284895783674747"
},
"remittanceInformationUnstructured": "RI2"
},
{
"transactionId": "T3",
"bookingDate": "2000-03-01",
"transactionAmount": {
"currency": "EUR",
"amount": "33"
},
"debtorName": "DN3"
}
]
}
}
JSON
importer = BankAccountInformationImporter.new(bank_account)
expect(importer.import!(content)).to eq(3)
expect(bank_account.import_continuation_point).to eq('E1')
expect(bank_account.last_transaction_date).to eq('2020-01-01'.to_date)
expect(bank_account.balance).to eq(22)
bt1 = bank_account.bank_transactions.find_by_external_id("T1")
expect(bt1.amount).to eq(11)
expect(bt1.date).to eq('2020-01-01'.to_date)
expect(bt1.text).to eq('DN1')
expect(bt1.iban).to eq('DE72957284895783674747')
expect(bt1.reference).to be(nil)
expect(bt1.receipt).to eq('AI1')
bt2 = bank_account.bank_transactions.find_by_external_id("T2")
expect(bt2.amount).to eq(-22)
expect(bt2.date).to eq('2010-02-01'.to_date)
expect(bt2.text).to eq('CN2')
expect(bt2.iban).to eq('CH9300762011623852957')
expect(bt2.reference).to eq('RI2')
expect(bt2.receipt).to be(nil)
bt3 = bank_account.bank_transactions.find_by_external_id("T3")
expect(bt3.amount).to eq(33)
expect(bt3.date).to eq('2000-03-01'.to_date)
expect(bt3.text).to eq('DN3')
expect(bt3.iban).to be(nil)
expect(bt3.reference).to be(nil)
expect(bt3.receipt).to be(nil)
end
end