Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bank Account Data API integration through GoCardless #11

Merged
merged 25 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bd02fb8
Implement Bank::Client for communicating with GoCardless Bank Account…
murdho Dec 27, 2024
8c297d1
wip
murdho Dec 27, 2024
63d3a50
Make bank account data api client more OOP
murdho Dec 27, 2024
c537a92
Add some comments
murdho Dec 29, 2024
ee0de98
Get rid of credentials
murdho Dec 29, 2024
c3a5483
Use dotenv for managing credentials
murdho Dec 29, 2024
80858c5
Use consistent format in .env.example.erb
murdho Dec 29, 2024
8ad9982
Remove unrelated changes
murdho Dec 29, 2024
b22b5f9
Make sure AR encryption config is taken from env
murdho Dec 29, 2024
a671427
Add some tests for the Bank integration
murdho Dec 29, 2024
806fbff
Tweak formatting
murdho Dec 29, 2024
ee1d352
Add more Bank::Account tests
murdho Dec 29, 2024
d7995ef
Add machinery for checking test coverage
murdho Dec 29, 2024
80d0a91
Add .env.test for tests
murdho Dec 29, 2024
3fe4231
Merge branch 'main' into bank-integration
murdho Dec 29, 2024
9a6c4ad
Tweak some formatting
murdho Dec 29, 2024
c56eacc
Add Bank.connect and Bank.disconnect with tests
murdho Dec 29, 2024
bb8e0bb
Move class << self section to the beginning of class for readability
murdho Dec 29, 2024
bd04bc6
Add assert method for simple assertions
murdho Dec 29, 2024
abbaf19
Run tests with coverage in CI
murdho Dec 29, 2024
49219d1
ci: split system tests into separate step
murdho Dec 29, 2024
b9331f9
Remove parentheses from calls with double splat
murdho Dec 29, 2024
aedf4a5
Reorganize core_ext and add tests
murdho Dec 29, 2024
ca544d3
Rename test class
murdho Dec 29, 2024
f710199
Try to fix the core_ext loading issue
murdho Dec 29, 2024
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
4 changes: 4 additions & 0 deletions .env.example.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ SECRET_KEY_BASE=<%= SecureRandom.hex(64) %>
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=<%= SecureRandom.alphanumeric(32) %>
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=<%= SecureRandom.alphanumeric(32) %>
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=<%= SecureRandom.alphanumeric(32) %>

# Get yours from https://bankaccountdata.gocardless.com/user-secrets
GOCARDLESS_SECRET_ID=
GOCARDLESS_SECRET_KEY=
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ SECRET_KEY_BASE=8c6c7e76595083a2b5e0b72d70c7e0a1abe9e18a4f2e4808920d1a896c0258a3
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=ugv4fakdv28DyyCjx0KVy9Tpi2c2vgbL
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=u5YSUUdY8HKaYTbf6QWuwlPM5GnkGc4t
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=GjTAic8Ak0kkON6Ok40pZ9Jv1vRMi9HC

GOCARDLESS_SECRET_ID=fake_secret_id
GOCARDLESS_SECRET_KEY=fake_secret_key
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ jobs:
env:
RAILS_ENV: test
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test test:system
run: bin/rails db:test:prepare test:coverage

- name: Run system tests
env:
RAILS_ENV: test
run: bin/rails db:test:prepare test:system

- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
Expand Down
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
gem "csv", require: false
end

group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
gem "webmock", require: false
gem "simplecov", require: false
end

gem "faraday", "~> 2.12"
28 changes: 28 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -124,22 +124,34 @@ GEM
xpath (~> 3.2)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
csv (3.3.2)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.1)
dotenv (3.1.7)
drb (2.2.1)
ed25519 (1.3.0)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
importmap-rails (2.1.0)
Expand Down Expand Up @@ -181,6 +193,8 @@ GEM
mini_portile2 (2.8.8)
minitest (5.25.4)
msgpack (1.7.5)
net-http (0.6.0)
uri
net-imap (0.5.4)
date
net-protocol
Expand Down Expand Up @@ -291,6 +305,12 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
solid_cable (3.0.5)
actioncable (>= 7.2)
activejob (>= 7.2)
Expand Down Expand Up @@ -348,6 +368,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
Expand Down Expand Up @@ -376,8 +400,10 @@ DEPENDENCIES
bootsnap
brakeman
capybara
csv
debug
dotenv
faraday (~> 2.12)
importmap-rails
jbuilder
kamal
Expand All @@ -386,6 +412,7 @@ DEPENDENCIES
rails!
rubocop-rails-omakase
selenium-webdriver
simplecov
solid_cable
solid_cache
solid_queue
Expand All @@ -395,6 +422,7 @@ DEPENDENCIES
turbo-rails
tzinfo-data
web-console
webmock

BUNDLED WITH
2.6.2
52 changes: 52 additions & 0 deletions app/models/bank.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module Bank
extend self

SANDBOX_INSTITUTION_ID = "SANDBOXFINANCE_SFIN0000"

# https://gocardless.com/bank-account-data/coverage/
#
# `curl -sL https://docs.google.com/spreadsheets/d/1EZ5n7QDGaRIot5M86dwqd5UFSGEDTeTRzEq3D9uEDkM/export?format=csv`
# .then { CSV.parse it, headers: true }["Countries "]
# .filter { it&.length == 2 }
# .uniq
# .sort
# .join(" ")
# .then { puts "%w[#{it}]"}
COUNTRIES = %w[AT BE BG CY CZ DE DK EE ES FI FR GB GR HR HU IE IS IT LT LU LV MT NL NO PL PT RO SE SI SK]

def connect(institution, **opts)
Bank::Requisition.create institution, **opts
end

def disconnect(requisition)
requisition.delete
end

def countries
COUNTRIES
end

def institutions(country:)
Bank::Institution.find_by_country country
end

def institution(id)
Bank::Institution.find id
end

def sandbox_institution
Bank::Institution.find SANDBOX_INSTITUTION_ID
end

def requisitions
Bank::Requisition.all
end

def accounts(requisition_id:)
Bank::Requisition.find(requisition_id).accounts
end

def account(id)
Bank::Account.find id
end
end
59 changes: 59 additions & 0 deletions app/models/bank/account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
class Bank::Account
include Bank::Connection

attr_reader :id, :iban, :institution_id

class << self
def find(id)
connection
.get("accounts/#{id}/")
.body
.then { new **it }
end
end

def initialize(id:, **attrs)
@id = id
@iban = attrs[:iban]
@institution_id = attrs[:institution_id]
end

def balances
@balances ||= \
connection
.get("accounts/#{id}/balances/")
.body
.dig(:balances)
end

def details
@details ||= \
connection
.get("accounts/#{id}/details/")
.body
.dig(:account)
end

def transactions(from: nil, to: nil)
if from || to
fetch_transactions(from:, to:)
else
@transactions ||= fetch_transactions(from:, to:)
end
end

private
def fetch_transactions(from: nil, to: nil)
connection
.get("accounts/#{id}/transactions/", {
date_from: format_date(from),
date_to: format_date(to)
}.compact)
.body
.dig(:transactions, :booked)
end

def format_date(date_or_datetime_or_string)
date_or_datetime_or_string.try(:strftime, "%Y-%m-%d") || date_or_datetime_or_string
end
end
15 changes: 15 additions & 0 deletions app/models/bank/connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Bank::Connection
extend ActiveSupport::Concern

DB_TOKEN_NAME = :bank_token

included do
delegate :connection, to: :class
end

class_methods do
def connection
@connection ||= Bank::Http.client authorized_by: DB_TOKEN_NAME
end
end
end
30 changes: 30 additions & 0 deletions app/models/bank/http.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module Bank::Http
extend self

URL = "https://bankaccountdata.gocardless.com/api/v2/"
USER_AGENT = "github.com/murdho/spendbetter"

# Create an HTTP client suitable for communicating with the bank API.
#
# [<tt>authorized_by</tt>]
# Name for a Token for finding and storing API tokens. Optional.
def client(authorized_by: nil)
Faraday.new do |conn|
conn.url_prefix = URL
conn.headers = { "User-Agent": USER_AGENT }
conn.request :json

conn.use TokenAutoRefresh, token_name: authorized_by if authorized_by

conn.response :json, parser_options: { decoder: [ Jason, :parse ] }
conn.response :raise_error

conn.response(:logger, Rails.logger, headers: true, bodies: true) { add_logging_filters it } if Rails.env.local?
end
end

private
def add_logging_filters(logger)
logger.filter /(Authorization:\s+).*/, '\1[REDACTED]'
end
end
25 changes: 25 additions & 0 deletions app/models/bank/http/jason.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# JSON parser for converting all hash keys into underscore symbols. For example, +"transactionAmount"+ is transformed
# into +:transaction_amount+.
class Bank::Http::Jason
class << self
def parse(str, opts)
JSON
.parse(str, opts)
.then { deep_transform it }
end

private
def deep_transform(element)
case element
when Hash
element
.transform_keys { it.to_s.underscore.to_sym }
.transform_values { deep_transform it }
when Array
element.map { deep_transform it }
else
element
end
end
end
end
72 changes: 72 additions & 0 deletions app/models/bank/http/token_auto_refresh.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Faraday middleware for automatically refreshing and storing both access and refresh tokens.
#
# ==== Options
#
# [<tt>token_name</tt>]
# Specify name of the Token model record for finding and storing the API tokens.
#
# ==== Example
#
# Faraday.new do |conn|
# conn.use TokenAutoRefresh, token_name: :bla_bla
# end
class Bank::Http::TokenAutoRefresh < Faraday::Middleware
attr_reader :token

def on_request(env)
env.request_headers["Authorization"] = "Bearer #{access_token}"
end

private
def access_token
find_or_initialize_token

unless token.fresh?
token.with_lock do
if token.refreshable?
refresh_api_token
else
create_api_token
end
end
end

token.access_token
end

def refresh_api_token
save_token \
http_client
.post("token/refresh/", { refresh: token.refresh_token })
.body
end

def create_api_token
save_token \
http_client
.post("token/new/", {
secret_id: Rails.configuration.spendbetter.gocardless.fetch(:secret_id),
secret_key: Rails.configuration.spendbetter.gocardless.fetch(:secret_key)
})
.body
end

def save_token(new_tokens)
new_tokens => { access:, refresh:, access_expires:, refresh_expires: }

token.update!(
access_token: access,
refresh_token: refresh,
access_expires_in: access_expires,
refresh_expires_in: refresh_expires
)
end

def find_or_initialize_token
@token = Token.find_or_initialize_by name: options.fetch(:token_name)
end

def http_client
@http_client ||= Bank::Http.client
end
end
Loading
Loading