diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..fc52c192
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,24 @@
+
+# development
+.DS_Store
+docker-compose*
+Procfile*
+
+#git
+.git/
+.gitignore
+
+# ci
+.github/
+
+# logs
+logs/
+tmp/
+
+# local env
+.env*
+web.env.*
+
+# docs
+README*
+docs/
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..d4fabb8b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,37 @@
+# THIS IS A CONFIGURATION FILE
+# Adjust the settings matching to your system
+
+# passphrased used to encrypt bank-data in database
+# !!! make sure back that up !!!
+PASSPHRASE=
+
+# define auth service to use.
+# static: via accesstoken
+# oauth: via oauth server, provide 'OAUTH_SERVER' and 'JWT_SECRET' if oauth used
+AUTH_SERVICE=
+
+# when not using ebicsbox-internal db (e.g. not running with .with-db)
+DATABASE_URL=
+REDIS_URL=
+
+# error handling with Sentry
+# SENTRY_DSN=
+
+# error handling via rollbar
+# ROLLBAR_ACCESS_TOKEN=
+
+# optional
+# how often bank statements will be fetched (in minutes, default 60)
+# UPDATE_BANK_STATEMENTS_INTERVAL
+
+# how often transaction status will be updated via bank (in minutes, default 360)
+# UPDATE_PROCESSING_STATUS_INTERVAL
+
+# how often pending users will be checked for activation (in minutes, default 60)
+# ACTIVATE_EBICS_USER_INTERVAL
+
+# how often upcoming statments will be fetches (in minutes, default 60)
+# UPCOMING_STATEMENTS_INTERVAL
+
+# allow the inital setup of the system via the web interface
+# UI_INITIAL_SETUP=enabled
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e1947fb1..f9f8139f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,7 +33,7 @@ jobs:
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
- uses: sigstore/cosign-installer@v3.8.1
+ uses: sigstore/cosign-installer@v3.8.0
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
diff --git a/.rspec b/.rspec
new file mode 100644
index 00000000..ba899f26
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,3 @@
+--color
+--require pry
+--require spec_helper
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 00000000..9a5770ce
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,13 @@
+require:
+ - standard
+
+inherit_gem:
+ standard: config/base.yml
+
+AllCops:
+ TargetRubyVersion: 3.3
+ Exclude:
+ - db/**/*
+ - bin/*
+ - tmp/**/*
+ - vendor/**/*
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 00000000..47725433
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.3.2
diff --git a/.web.env b/.web.env
new file mode 100644
index 00000000..562deb09
--- /dev/null
+++ b/.web.env
@@ -0,0 +1,3 @@
+VIRTUAL_HOST=ebicsbox.local
+LETSENCRYPT_HOST=ebicsbox.local
+LETSENCRYPT_EMAIL=maxim@railslove.com
diff --git a/.web.env.example b/.web.env.example
new file mode 100644
index 00000000..0713cb85
--- /dev/null
+++ b/.web.env.example
@@ -0,0 +1,7 @@
+# THIS IS A CONFIGURATION FILE
+# Adjust the settings matching to your system
+
+# this is used for nginx & letsencrypt to automatically provide an SSL certificate
+VIRTUAL_HOST=
+LETSENCRYPT_HOST=
+LETSENCRYPT_EMAIL=
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..a68153d1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,349 @@
+# Changelog
+
+## 2.3.0
+- `[SECURITY]` Update dependencies that contained security issues
+- `[FIX]` Add sentry error logging for sidekiq jobs
+- `[HOUSEKEEPING]` Remove outdated code from v1 api
+- `[HOUSEKEEPING]` Enrich development setup
+
+## 2.2.0
+- `[ENHANCEMENT]` better error handling, more logging and sentry/ rollbar tracking
+
+## 2.1.1
+- `[FIX]` correct broken ruby import hierarchy. This was broken in Version `2.0`
+- `[FIX]` add http status 500 as swagger responses
+
+## 2.1.0
+- `[ENHANCEMENT]` Allow the initial setup of the box to be performed via web interface `/setup`
+
+## 2.0.2
+- `[ENHANCEMENT]` Add container healthcheck
+
+## 2.0.1
+- `[FIX]` Migrate to new sentry
+
+## 2.0.0
+- `[BREAKING]` Removes API V1
+- `[ENHANCEMENT]` Adds Support for ruby 3
+- `[ENHANCEMENT]` Updates epics to 2.4
+- `[HOUSEKEEPING]` Further dependency updates
+
+## 1.5.0
+- `[ENHANCEMENT]` Enables `/transactions` endpoint to filter by `end_to_end_reference`.
+
+## 1.4.0
+- `[ENHANCEMENT]` Updates pain formats
+ - 001.003.03 -> 001.001.03 (Credit Transfer)
+ - 008.003.02 -> 008.001.02 (Direct Debit)
+
+## 1.3.32
+- Nothing to see here
+
+## 1.3.31
+- `[BUGFIX]` handle transport client errors properly
+- `[BUGFIX]` retry on failure in FetchProcessingStatus job
+
+## 1.3.30
+- `[BUGFIX]` Prevents stuck transactions, if `#execute!` fails due to network error or similar problems
+
+## 1.3.29
+- `[ENHANCEMENT]` Expose `ebics_order_id` and `ebics_transaction_id` in credits & debits endpoints
+- `[ENHANCEMENT]` Allow filtering credits & debits by `status`
+- `[HOUSEKEEPING]` Update ruby to 2.5.8
+
+## 1.3.27, 1.3.28
+
+- `[ENHANCEMENT]` Allows disabling sidekiq jobs by setting the env var to 0
+- `[HOUSEKEEPING]` Allows Bundler < 2 again
+
+## 1.3.26
+
+- `[HOUSEKEEPING]` Updates used ruby to 2.5.7
+- `[BUGFIX]` Fixes several docker issues
+
+## 1.3.25
+
+- `[BUGFIX]` Ensure alphanumeric transaction IDs can be handled properly
+
+## 1.3.20 - 13.24
+
+- `[ENHANCEMENT]` Use transaction-id to calculate checksum if available
+- `[HOUSEKEEPING]` Migration will recalculate checksums for statements
+- `[BUGFIX]` Migration to 1.3.20 could fail when statement data in invalid due to previous fixed issues
+
+## 1.3.19
+
+- `[ENHANCEMENT]` Adds transactions detail endpoint to v2 api
+
+## 1.3.16 - 1.3.18
+
+- `[ENHANCEMENT]` Improves duplication checks for statements to handle edge-cases
+
+## 1.3.15
+
+- `[ENHANCEMENT]` Enhances the statement SHA calculation further to drop whitespaces from references. camt53 / mt940 seem to handle them differently
+
+## 1.3.14
+
+- `[ENHANCEMENT]` Enhances Statement SHA calculation to prevent false positive duplicate checks
+
+## 1.3.13
+
+- `[HOUSEKEEPING]` Updates CMXL-Gem to parse more weird bank responses
+
+## 1.3.12
+
+- `[ENHANCEMENT]` Improves logging when failing to import statement due to unknown subaccount
+
+## 1.3.11
+
+- `[BUGFIX]` Changing interval to fetch upcoming statements now uses correct class
+
+## 1.3.10
+
+- `[ENHANCEMENT]` Queues VMK-jobs separately so a single failure does not interfere with other imports
+- `[HOUSEKEEPING]` Updates CMXL-Gem to fix some weird bank responses
+
+## 1.3.9
+
+- `[FEATURE]` Create organizations through v2 API
+- `[ENHANCEMENT]` Ensure contradicting params for credit_transfers are handled (i.e. urgent with foreign currency)
+- `[HOUSEKEEPING]` Aligns code with current rubocop guidelines
+- `[HOUSEKEEPING]` Improves v2 management ebics_users endpoint-implementation
+
+## 1.3.8
+
+- `[FEATURE]` Now supports rollbar, README updated
+- `[ENHANCEMENT]` Exception Trackers now work in dev mode too
+
+## 1.3.7
+
+- `[ENHANCEMENT]` Adds ability to delete users via v2 management
+- `[HOUSEKEEPING]` Improves docker setup
+- `[HOUSEKEEPING]` Adds documentation for docker setup
+
+## 1.3.6
+
+- `[ENHANCEMENT]` Fetch Statements Job now runs parallelized for multiple accounts
+- `[ENHANCEMENT]` Scheduled Jobs won't get retried anymore
+- `[HOUSEKEEPING]` Adds advanced metrics for Heroku
+- `[HOUSEKEEPING]` Reduces log output in INFO mode
+- `[HOUSEKEEPING]` Reduces default timerange to fetch statements for to 7 days (was 30)
+- `[HOUSEKEEPING]` Reduces default timerange to fetch upcoming statements for to 30 days (was 180)
+- `[HOUSEKEEPING]` Adds advanced metrics support for heroku
+
+## 1.3.5
+
+- `[BUGFIX]` Don't remove Rakefile from Dockerfile so rake tasks can be executed correctly
+
+## 1.3.4
+
+- `[ENHANCEMENT]` EbicsUser users partner to determine all accounts belong to the same bank
+
+## 1.3.3
+
+- `[BUGFIX]` Fake-Mode now creates direct debits qas debits
+
+## 1.3.2
+
+- `[ENHANCEMENT]` Fake-Mode now handles information for direct debits properly
+- `[HOUSEKEEPING]` Tagging docker images works now with semaphore
+
+## 1.3.1
+
+- `[FEATURE]` Fake-Mode now generated proper direct debit statements
+
+## 1.3.0
+
+- `[FEATURE]` supports MT942 / VMK
+ set `UPCOMING_STATEMENTS_INTERVAL` to define fetching interval (minutes,_default: 60_)
+ upcoming transactions will appear as statements in the `/statements` endpoint
+- `[FEATURE]` retry failed webhooks (see API-Docs at `/docs` for more details)
+- `[ENHANCEMENT]` multiple accounts per ebics_user
+
+## 1.2.1
+
+- `[BUGFIX]` fixes migrations missing statements public-id default value
+
+## 1.2.0
+
+- `[HOUSEKEEPING]` removes clockwork in favor of sidekiq-scheduler
+- `[FEATURE]` added env-variable (`ACTIVATE_EBICS_USER_INTERVAL`) to defined interval (minutes) of activation jobs (_default: 60_)
+- `[ENHANCEMENT]` account activation is now a recurring job to prevent missing activation due to queue failure
+- `[ENHANCEMENT]` forces SSL unless DISABLE_SSL_FORCE is set (to true obviously :])
+
+## 1.1.3
+
+- `[ENHANCEMENT]` fake adapter now adds reference to info field for statements
+
+## 1.1.2
+
+- `[BUGFIX]` account activation was not triggered due to incorrect sidekiq config
+
+## 1.1.1
+
+- `[BUGFIX]` fixes docker-compose file running incorrect server command
+- `[ENHANCEMENT]` makes docker services run migration before startup
+- `[HOUSEKEEPING]` updates heroku doku
+
+## 1.1.0
+
+- `[ENHANCEMENT]` renames subscribers to ebics user to lessen complexity
+
+## 1.0.1
+
+- `[DEPS]` updates epics to work with sparkasse again
+- `[HOUSEKEEPING]` makes travis build docker files on success
+
+## 1.0.0
+
+- `[FEATURE]` direct debits for API v2
+- `[BUGFIX]` initial migration now setups initial user
+
+## 0.9.1
+
+- `[BUGFIX]` migrations not passing
+- `[OTHERS]` updates dependencies because of vulnerabilities
+- `[OTHERS]` replaces byebug with pry
+
+## 0.9
+
+- `[BUGFIX]` fixes setup issue
+
+## 0.8
+
+- `[BUGFIX]` dropped setup migrations restored
+
+## 0.7.2.2
+
+- Fix webhook signature mechanism
+
+## 0.7.2.1
+
+- Ignore access_token attribute in accounts#update V1 API call
+
+## 0.7.2
+
+- bound postgresql to version 9.6.1 (be careful! manual migration or local overriding of this is necessary)
+- BankStatements are bound to a year now, as some banking institutes tend to reuse their bank statement sequence number every year
+
+## 0.7.1.2
+
+- Bugfix for new supervisord version and problems logging to stdout
+
+## 0.7.1.1
+
+- Bugfix in FetchStatements Worker
+
+## 0.7.1
+
+Make scheduler intervals configurable.
+
+- Set interval between retrieval of bank statements via UPDATE_BANK_STATEMENTS_INTERVAL (in minutes / default: 30)
+- Set interval between retrieval of processing status reports via UPDATE_PROCESSING_STATUS_INTERVAL (in minutes / default: 300)
+
+## 0.7
+
+We ditched jruby in favor of ruby. This is great news!
+API Changes for V2:
+
+- Events are now available for V2
+- Credits higher than 120.000€ are now allowed
+- Bic-less transactions are now allowed - be careful, your bank may get angry
+
+## 0.6.1
+
+We're now using supervisord to spawn processes on startup
+
+## 0.6.0
+
+Integrated CAMT.053 parsing
+
+- It is now possible to switch between mt940 / camt53 for each account
+- Statements are fetched more frequently w/ camt53
+
+_Important_: Switching to C53 requires to remove old mt940 statements for the according account.
+Checksum calculation will not match C53 and mt940 statements!
+
+## 0.5.4
+
+Bugfix for running latest migrations without complications
+
+## 0.5.3
+
+Minor features and optimizations regarding statements
+
+- Expose a unique statement id so clients can use it to prevent duplicates while importing
+- Optimize checksum calculation for statements, so similar looking entries are imported correctly
+
+## 0.5.2
+
+Bugfix release
+
+- Handle exceptions when creating multiple bank statements at once
+- Add a migration to clean up a previous migration fuckup and add webhook tokens to organizations
+
+## 0.5.1
+
+Bugfix release
+
+- Migrations run properly on setup
+- Updated documentation
+- Updated docker compose file
+
+## 0.5.0
+
+This release is a major rewrite of the way we handle incoming data on statements and transactions.
+We now embrace the concept of account statements which include multiple transactions. It allows us
+to store raw MT940 data economically. Moreover, we can easily rebuild statement data in case of
+issues we are having with MT940 parsing.
+
+- Store all incoming bank statements in a separate table
+- Link account statements to imported bank statements
+- Rebuild statement data from bank statements, as we had an issue with MT940 parsing
+- Update to latest CMXL code to resolve issues with MT940 parsing.
+
+In addition to that, this release also includes a few additions in preparation of our upcoming
+distributed signature feature:
+
+- Users can add their subscriber id (only one) for each account via non-management API endpoint
+- Expose more data on accounts (include subscriber for current user)
+
+## 0.4.0
+
+This release focuses on how to authenticate. There is an accompanying project to perform user
+on-boarding and managing core data. By switching to OAuth we can provide a nice UI without having
+to include it in the box.
+
+- Drop support for organization management tokens
+- Add user admin flag to limit access to management features
+
+## 0.3.0
+
+- Expose events via https://box/events (including information about webhook deliveries)
+- Expose raw MT940 in statements when requested via header or query parameter
+- Harden security around webhook payload verification
+- Fix issues with interactive documentation using http instead of https
+- Fix minor mistakes in documentation
+
+## 0.2.0
+
+This is the first release where we will apply the semantic versioning scheme. All changes listed
+below have been added in the last few releases. Expect that we move forward in a more organized way.
+
+- Switch to JRuby 9.0.5.0
+- Proper support of OAuth Bearer tokens
+- Track account balance
+- Manual triggering of statement retrieval
+- HTTP authentication for webhooks
+- Fallback to non-T subscribers if none exists
+- Improved onboarding API (INI letter, access tokens, etc.)
+- Additions to documentation
+- Automatic host detection for documentation
+- Minimal fake backend for triggering statements (immediate dev feedback)
+- Proper support for Deutsche Bank's MT940 file format
+- Proper support for Deutsche Bank's sub-accounts format
+- Improved security by self-managed docker base images
+- Reduced JVM memory footprint
+- Additional logging for queued jobs
+- Everything else ;)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..3954ca6a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+FROM ruby:3.3.2-slim
+ARG DOCKER_TAG
+ENV APP_VERSION=$DOCKER_TAG
+
+RUN apt-get update && apt-get install -y git supervisor build-essential zlib1g-dev libpq-dev curl
+
+# throw errors if Gemfile has been modified since Gemfile.lock
+
+RUN bundle config --global frozen 1
+
+RUN mkdir -p /usr/ebicsbox
+WORKDIR /usr/ebicsbox
+
+ADD Gemfile /usr/ebicsbox/
+ADD Gemfile.lock /usr/ebicsbox/
+RUN gem install bundler -v 2.3.22
+RUN bundle install
+
+ADD . /usr/ebicsbox
+COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+
+# Clean up
+RUN rm -rf pkg
+
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod 755 /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
+
+CMD ["bin/start", "all"]
+HEALTHCHECK --interval=30s --timeout=3s CMD bin/healthchecks/all
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 00000000..7277a8a6
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+ruby "3.3.2"
+
+gem "activesupport"
+gem "camt_parser", git: "https://github.com/railslove/camt_parser.git"
+gem "cmxl", git: "https://github.com/railslove/cmxl"
+gem "epics"
+gem "faraday"
+gem "grape"
+gem "grape-entity"
+gem "grape-swagger"
+gem "grape-swagger-entity"
+gem "jwt"
+gem "king_dtaus", git: "https://github.com/railslove/king_dtaus_ruby_3.git"
+gem "nokogiri"
+gem "pg"
+gem "pry"
+gem "puma"
+gem "rack-cors", require: false
+gem "rake"
+gem "sepa_king"
+gem "sequel"
+gem "sidekiq"
+gem "sidekiq-scheduler"
+
+# exception management
+gem "rollbar"
+gem "sentry-ruby"
+gem "sentry-sidekiq"
+
+gem "barnes"
+gem "rack-ssl-enforcer"
+
+group :development do
+ gem "rubocop"
+end
+
+group :development, :test do
+ gem "airborne"
+ gem "database_cleaner-sequel"
+ gem "dotenv"
+ gem "fabrication"
+ gem "faker"
+ gem "foreman"
+ gem "rspec"
+ gem "standard"
+ gem "timecop"
+ gem "webmock"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 00000000..d641848d
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,302 @@
+GIT
+ remote: https://github.com/railslove/camt_parser.git
+ revision: 3996d9dce6c4dbdc950c6d607102e3c4ce52ca52
+ specs:
+ camt_parser (1.0.2)
+ nokogiri
+
+GIT
+ remote: https://github.com/railslove/cmxl
+ revision: b92aea3de958426119d76d043a886233a313f43f
+ specs:
+ cmxl (1.5.0)
+ rchardet
+
+GIT
+ remote: https://github.com/railslove/king_dtaus_ruby_3.git
+ revision: 28b077aa6b2cf5590e322d0d207236adccf2d543
+ specs:
+ king_dtaus (2.0.4)
+ i18n
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activemodel (6.1.7.8)
+ activesupport (= 6.1.7.8)
+ activesupport (6.1.7.8)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ zeitwerk (~> 2.3)
+ addressable (2.8.7)
+ public_suffix (>= 2.0.2, < 7.0)
+ airborne (0.3.7)
+ activesupport
+ rack
+ rack-test (>= 1.1.0, < 2.0)
+ rest-client (>= 2.0.2, < 3.0)
+ rspec (~> 3.8)
+ ast (2.4.2)
+ barnes (0.0.9)
+ multi_json (~> 1)
+ statsd-ruby (~> 1.1)
+ base64 (0.2.0)
+ bigdecimal (3.1.9)
+ builder (3.3.0)
+ coderay (1.1.3)
+ concurrent-ruby (1.3.4)
+ connection_pool (2.5.0)
+ crack (1.0.0)
+ bigdecimal
+ rexml
+ database_cleaner-core (2.0.1)
+ database_cleaner-sequel (2.0.2)
+ database_cleaner-core (~> 2.0.0)
+ sequel
+ diff-lcs (1.5.1)
+ domain_name (0.5.20190701)
+ unf (>= 0.0.5, < 1.0.0)
+ dotenv (2.8.1)
+ dry-core (1.0.1)
+ concurrent-ruby (~> 1.0)
+ zeitwerk (~> 2.6)
+ dry-inflector (1.1.0)
+ dry-logic (1.5.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0, < 2)
+ zeitwerk (~> 2.6)
+ dry-types (1.7.2)
+ bigdecimal (~> 3.0)
+ concurrent-ruby (~> 1.0)
+ dry-core (~> 1.0)
+ dry-inflector (~> 1.0)
+ dry-logic (~> 1.4)
+ zeitwerk (~> 2.6)
+ epics (2.4.0)
+ faraday (>= 1.10.0)
+ nokogiri (>= 1.16.5)
+ rexml (>= 3.2.8)
+ rubyzip (>= 2.3.2)
+ et-orbi (1.2.11)
+ tzinfo
+ fabrication (2.22.0)
+ faker (2.22.0)
+ i18n (>= 1.8.11, < 2)
+ faraday (2.9.2)
+ faraday-net_http (>= 2.0, < 3.2)
+ faraday-net_http (3.1.0)
+ net-http
+ foreman (0.88.1)
+ fugit (1.11.1)
+ et-orbi (~> 1, >= 1.2.11)
+ raabro (~> 1.4)
+ grape (1.7.0)
+ activesupport
+ builder
+ dry-types (>= 1.1)
+ mustermann-grape (~> 1.0.0)
+ rack (>= 1.3.0)
+ rack-accept
+ grape-entity (0.10.2)
+ activesupport (>= 3.0.0)
+ multi_json (>= 1.3.2)
+ grape-swagger (1.4.2)
+ grape (~> 1.3)
+ grape-swagger-entity (0.5.3)
+ grape-entity (>= 0.6.0)
+ grape-swagger (>= 1.2.0)
+ hashdiff (1.1.0)
+ http-accept (1.7.0)
+ http-cookie (1.0.6)
+ domain_name (~> 0.5)
+ i18n (1.14.5)
+ concurrent-ruby (~> 1.0)
+ iban-tools (1.2.1)
+ json (2.7.2)
+ jwt (2.8.2)
+ base64
+ language_server-protocol (3.17.0.3)
+ lint_roller (1.1.0)
+ method_source (1.1.0)
+ mime-types (3.5.2)
+ mime-types-data (~> 3.2015)
+ mime-types-data (3.2024.0604)
+ mini_portile2 (2.8.8)
+ minitest (5.15.0)
+ multi_json (1.15.0)
+ mustermann (2.0.2)
+ ruby2_keywords (~> 0.0.1)
+ mustermann-grape (1.0.2)
+ mustermann (>= 1.0.0)
+ net-http (0.4.1)
+ uri
+ netrc (0.11.0)
+ nio4r (2.7.4)
+ nokogiri (1.18.3)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ parallel (1.25.1)
+ parser (3.3.3.0)
+ ast (~> 2.4.1)
+ racc
+ pg (1.5.6)
+ pry (0.14.2)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ public_suffix (4.0.7)
+ puma (6.4.3)
+ nio4r (~> 2.0)
+ raabro (1.4.0)
+ racc (1.8.1)
+ rack (2.2.10)
+ rack-accept (0.4.5)
+ rack (>= 0.4)
+ rack-cors (2.0.2)
+ rack (>= 2.0.0)
+ rack-ssl-enforcer (0.2.9)
+ rack-test (1.1.0)
+ rack (>= 1.0, < 3)
+ rainbow (3.1.1)
+ rake (13.2.1)
+ rchardet (1.8.0)
+ redis (4.8.1)
+ regexp_parser (2.9.2)
+ rest-client (2.1.0)
+ http-accept (>= 1.7.0, < 2.0)
+ http-cookie (>= 1.0.2, < 2.0)
+ mime-types (>= 1.16, < 4.0)
+ netrc (~> 0.8)
+ rexml (3.3.9)
+ rollbar (3.5.2)
+ rspec (3.13.0)
+ rspec-core (~> 3.13.0)
+ rspec-expectations (~> 3.13.0)
+ rspec-mocks (~> 3.13.0)
+ rspec-core (3.13.0)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.1)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.1)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-support (3.13.1)
+ rubocop (1.64.1)
+ json (~> 2.3)
+ language_server-protocol (>= 3.17.0)
+ parallel (~> 1.10)
+ parser (>= 3.3.0.2)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.31.3)
+ parser (>= 3.3.1.0)
+ rubocop-performance (1.21.1)
+ rubocop (>= 1.48.1, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
+ ruby-progressbar (1.13.0)
+ ruby2_keywords (0.0.5)
+ rubyzip (2.3.2)
+ rufus-scheduler (3.9.1)
+ fugit (~> 1.1, >= 1.1.6)
+ sentry-ruby (5.22.1)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ sentry-sidekiq (5.22.1)
+ sentry-ruby (~> 5.22.1)
+ sidekiq (>= 3.0)
+ sepa_king (0.12.0)
+ activemodel (>= 3.1)
+ iban-tools
+ nokogiri
+ sequel (5.81.0)
+ bigdecimal
+ sidekiq (6.5.12)
+ connection_pool (>= 2.2.5, < 3)
+ rack (~> 2.0)
+ redis (>= 4.5.0, < 5)
+ sidekiq-scheduler (4.0.3)
+ redis (>= 4.2.0)
+ rufus-scheduler (~> 3.2)
+ sidekiq (>= 4, < 7)
+ tilt (>= 1.4.0)
+ standard (1.39.1)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.0)
+ rubocop (~> 1.64.0)
+ standard-custom (~> 1.0.0)
+ standard-performance (~> 1.4)
+ standard-custom (1.0.2)
+ lint_roller (~> 1.0)
+ rubocop (~> 1.50)
+ standard-performance (1.4.0)
+ lint_roller (~> 1.1)
+ rubocop-performance (~> 1.21.0)
+ statsd-ruby (1.5.0)
+ tilt (2.4.0)
+ timecop (0.9.10)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unf (0.1.4)
+ unf_ext
+ unf_ext (0.0.9.1)
+ unicode-display_width (2.5.0)
+ uri (0.13.0)
+ webmock (3.23.1)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ zeitwerk (2.6.16)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activesupport
+ airborne
+ barnes
+ camt_parser!
+ cmxl!
+ database_cleaner-sequel
+ dotenv
+ epics
+ fabrication
+ faker
+ faraday
+ foreman
+ grape
+ grape-entity
+ grape-swagger
+ grape-swagger-entity
+ jwt
+ king_dtaus!
+ nokogiri
+ pg
+ pry
+ puma
+ rack-cors
+ rack-ssl-enforcer
+ rake
+ rollbar
+ rspec
+ rubocop
+ sentry-ruby
+ sentry-sidekiq
+ sepa_king
+ sequel
+ sidekiq
+ sidekiq-scheduler
+ standard
+ timecop
+ webmock
+
+RUBY VERSION
+ ruby 3.3.2p78
+
+BUNDLED WITH
+ 2.3.22
diff --git a/Procfile b/Procfile
new file mode 100644
index 00000000..7a7d0940
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+web: bundle exec rackup -p $PORT
+worker: bundle exec sidekiq -C ./config/sidekiq.yml -r ./config/sidekiq.rb
diff --git a/README.md b/README.md
index 8f30297f..e15f0a06 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,173 @@
-# EBICSBox
+# Epics::Box
-> **⚠️ This repository is no longer actively maintained.**
->
-> EBICSBox has evolved into a commercial product. If you're interested in a hosted or licensed version with EBICS 3.0 support, instant payments, and more – get in touch: kontakt@railslove.com
->
-> The [epics](https://github.com/railslove/epics) Ruby gem, which powers the EBICS protocol layer, remains open source.
+
+
+Epics Box is a self-contained solution to handle SEPA credit/debits and bank statement
+reconciliation.
+
+It offers a HTTP interface and can be integrated with different message queueing systems
+
+## Get started
+In order to kickstart the project you can choose what fits you preferences.
+
+## Requirements
+
+* ruby 3.2.2
+* postgres 15 (when not using docker-compose)
+* redis 4 (when not using docker-compose)
+* docker and docker-compose (optional)
+
+### Locally
+* have a **postgres** server running
+* have a **redis** server running
+* install all ruby **dependencies**: `bundle install`
+* for dotenv-rails copy `.env.example` to `.env` and update values if needed
+* to prepare **development database**: `createdb ebicsbox`
+
+### Docker
+* Spin up the docker-compose project with the web config: `docker-compose -f docker-compose.with_db.yml up`
+
+
+## Getting started
+
+ $ createdb ebicsbox
+ $ bundle exec bin/migrate
+
+## Development
+
+Run it:
+
+ $ foreman start
+
+## Installation
+
+Run it:
+
+ $ foreman start
+
+## Configuration
+
+Set the following environment variables:
+
+- `PASSPHRASE`
+- `AUTH_SERVICE`
+ - `static` - _auth via access_token_
+ - `oauth` - _oauth, also requires server and jwt details, see .env.example_
+
+If you want the box to be available via a custom (sub-)domain, also provide these
+
+- `VIRTUAL_HOST`
+- `LETSENCRYPT_HOST`
+- `LETSENCRYPT_EMAIL`
+
+If you want to use a custom postgres instance provide the database connection strings:
+
+- `DATABASE_URL`
+- `TEST_DATABASE_URL`
+
+see config/configuration.rb
+
+SSL forcing can be disabled by setting
+
+- `DISABLE_SSL_FORCE`
+
+You can enable webhook payload encryption by setting
+
+- `WEBHOOK_ENCRYPTION_KEY`
+
+It expects to be a base64-encoded RSA public key in PEM format (see below).
+
+
+you can store these in a local .env file for development.
+
+It's done via environment variables. You can utilize a `.env` file while
+developing locally. Please revise `.env.example` for a overview
+of needed parameters.
+
+### Generate a secret token
+
+In order to ensure that webhooks are originating from your EbicsBox and have not been modified, we
+sign each webhook with a predefined secret. Each box should have a unique secret key. In order to
+generate one, you can use the following command:
+
+```bash
+ ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
+```
+
+### Generate a Webhook encryption key
+
+
+If OpenSSL is not installed, please refer to the OpenSSL documentation for installation instructions specific to your operating system.
+
+#### Step 1: Generate the Private-Public Keypair
+
+To generate the private-public keypair, follow these steps:
+
+1. Open your terminal or command prompt.
+
+2. Run the following command to generate a private key file named `private_key.pem`:
+```bash
+ openssl genpkey -algorithm RSA -out private_key.pem
+```
+
+3. You will be prompted to set a passphrase for the private key. Choose a strong passphrase and remember it for future use.
+
+4. Run the following command to generate the corresponding public key file named `public_key.pem`:
+
+```bash
+ openssl rsa -pubout -in private_key.pem -out public_key.pem
+```
+
+5. Remember to keep the private key (`private_key.pem`) secure and do not share it with anyone.
+
+#### Step 2: Encode the Public Key in Base64
+
+To encode the public key in Base64, follow these steps:
+
+1. Use the following command to encode the public key in Base64:
+
+```bash
+openssl base64 -in public_key.pem -out public_key_base64.txt
+```
+
+2. The public key is now encoded in Base64 and saved as `public_key_base64.txt`. The file contains the Base64-encoded public key.
+
+Congratulations! You have successfully generated a private-public keypair, converted the public key to a `.pem` file, and encoded it in Base64. You can now use it for `WEBHOOK_ENCRYPTION_KEY` (see above).
+
+## Usage
+
+see [docs.ebicsbox.apiary.io](http://docs.ebicsbox.apiary.io)
+
+### Tests
+
+We are using RSpec to test this project. In order to execute all specs once, run `bundle exec rspec`.
+
+To migrate your test database run the following command:
+
+```bash
+ $ `ENVIRONMENT`=test bundle exec bin/migrate
+```
+
+### Error Tracking
+
+The ebicsbox enables [sentry](https://sentry.io/) or [rollbar](https://rollbar.com/) as the error tracking software of choice.
+
+_using sentry_ \
+Define `SENTRY_DSN` via an environment variable to enable error tracking via `sentry`
+
+_using rollbar_ \
+Define `ROLLBAR_ACCESS_TOKEN` via an environment variable to enable error tracking via `rollbar`
+
+### Documentation
+
+Our goal is to provide an always up-to-date documentation from within the app.
+
+Documentation is available at http://YOUR-HOST/docs
+
+## Contributing
+
+1. Fork it ( https://github.com/[my-github-username]/epics-http/fork )
+2. Create your feature branch (`git checkout -b my-new-feature`)
+3. Commit your changes (`git commit -am 'Add some feature'`)
+4. Push to the branch (`git push origin my-new-feature`)
+5. Create a new Pull Request
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 00000000..8c298479
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require "sequel"
+# Load application
+require "./config/configuration"
+
+namespace :generate do
+ desc "Generate a timestamped, empty Sequel migration."
+ task :migration, :name do |_, args|
+ if args[:name].nil?
+ puts "You must specify a migration name (e.g. rake generate:migration[create_events])!"
+ exit false
+ end
+
+ content = "Sequel.migration do\n up do\n \n end\n\n down do\n \n end\nend\n"
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
+ filename = File.join(File.dirname(__FILE__), "migrations", "#{timestamp}_#{args[:name]}.rb")
+
+ File.open(filename, "w") do |f|
+ f.puts content
+ end
+
+ puts "Created the migration #{filename}"
+ end
+end
+
+namespace :migration_tasks do
+ desc "calculate SHAs of bank_statements"
+ task :calculate_bank_statements_sha do
+ env = ENV.fetch("RACK_ENV", :development)
+ if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+ end
+
+ require "./config/bootstrap"
+ require "./box/models/bank_statement"
+ require "./lib/checksum_generator"
+
+ i = 0
+ statements = Box::BankStatement.where(sha: nil)
+
+ p "Found #{statements.count} Bank Statements without a SHA."
+ next if statements.count.zero?
+
+ p "Recalculating Bank Statement SHAs."
+
+ statements.each do |bs|
+ payload = [
+ bs.account_id,
+ bs.year,
+ bs.content
+ ]
+
+ bs.update(sha: ChecksumGenerator.from_payload(payload))
+
+ i += 1
+ end
+
+ p "Updated #{i} Bank Statement SHAs."
+ end
+
+ # this should ONLY be run via migration migrations/20191217114900_recalculate_statement_sha.rb
+ desc "calculate new SHA"
+ task :calculate_new_sha do
+ env = ENV.fetch("RACK_ENV", :development)
+ if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+ end
+
+ require "./config/bootstrap"
+ require "./box/models/account"
+ require "./box/models/statement"
+ require "./box/models/bank_statement"
+ require "./lib/checksum_updater"
+
+ # safe guard to only run this task when temp checksum field is available
+ next unless Box::Statement.columns.include?(:sha2)
+
+ account_ids = Box::Account.all_active_ids
+ account_ids.each.with_index(1) do |account_id, idx|
+ pp "Processing Account #{idx} / #{account_ids.count}"
+
+ bank_statements = Box::BankStatement.where(account_id: account_id).all
+ bank_statements.each do |bank_statement|
+ parser = bank_statement.content.starts_with?(":") ? Cmxl : CamtParser::Format053::Statement
+ begin
+ result = parser.parse(bank_statement.content)
+ transactions = result.is_a?(Array) ? result.first.transactions : result.transactions
+
+ transactions.each do |transaction|
+ ChecksumUpdater.new(transaction, bank_statement.remote_account).call
+ end
+ rescue => e
+ p "--- ERROR ---"
+ p bank_statement.id
+ p e
+ p "--- !ERROR ---"
+ end
+ end
+ end
+
+ Box::Statement.where(sha2: nil).each do |statement|
+ remote_account = statement&.bank_statement&.remote_account
+ payload = ::ChecksumUpdater.new(statement, remote_account).send(:new_checksum_payload)
+ sha = ChecksumGenerator.from_payload(payload)
+ if Box::Statement.find(sha2: sha).present?
+ # prevent duplicates
+ pp "Statement #{statement.id} has duplicate sha: #{sha}"
+ next
+ end
+ statement.update(sha2: sha)
+ end; nil
+ end
+
+ desc "copies partner value to ebics_users"
+ task :copy_partners do
+ env = ENV.fetch("RACK_ENV", :development)
+ if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+ end
+
+ require "./config/bootstrap"
+ require "./box/models/ebics_user"
+
+ Box::EbicsUser.where(partner: nil).each do |ebics_user|
+ ebics_user.update(partner: ebics_user.accounts.first&.partner)
+ end
+ end
+end
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..be6ce7af
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,7 @@
+# Reporting Security Issues
+
+The Railslove team takes security bugs in the `ebicsbox` seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
+
+To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/railslove/ebicsbox/security/advisories/new) tab.
+
+The Railslove team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will inform you about the progress towards a fix and full announcement and may ask for additional information or guidance.
\ No newline at end of file
diff --git a/bin/console b/bin/console
new file mode 100755
index 00000000..2a4299b9
--- /dev/null
+++ b/bin/console
@@ -0,0 +1,23 @@
+#!/usr/bin/env ruby
+
+env = ENV.fetch("RACK_ENV", :development)
+if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+end
+
+# Load environment
+require "bundler"
+Bundler.setup(:default, env)
+
+# Start pry session
+require_relative "../config/bootstrap"
+
+# Load all files for console
+Dir[File.join(File.dirname(__FILE__), "../box/**/*.rb")].sort.map { |file| require_relative(file.gsub("bin/", "")) }
+
+require "pry"
+module Box
+ pry # rubocop:disable Lint/Debugger
+end
diff --git a/bin/healthchecks/all b/bin/healthchecks/all
new file mode 100755
index 00000000..7b6a6bf7
--- /dev/null
+++ b/bin/healthchecks/all
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+hasHealthyWebProcess() {
+ if curl -f "http://localhost:${PORT:-5000}/health"; then
+ exit 0
+ else
+ exit 1
+ fi
+}
+
+hasRunningSidekiqProcess() {
+ if [ $(bundle exec sidekiqmon processes | grep -Po '\d' | awk '{s+=$1} END {print s}') -gt 0 ]; then
+ exit 0
+ else
+ exit 1
+ fi
+}
+
+# Combined check
+if hasHealthyWebProcess && hasRunningSidekiqProcess; then
+ exit 0
+else
+ exit 1
+fi
diff --git a/bin/healthchecks/server b/bin/healthchecks/server
new file mode 100755
index 00000000..db6194f0
--- /dev/null
+++ b/bin/healthchecks/server
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+SERVER_PORT="${PORT:-5000}"
+
+curl -f "http://localhost:${SERVER_PORT}/health" || exit 1
diff --git a/bin/healthchecks/worker b/bin/healthchecks/worker
new file mode 100755
index 00000000..9d2854d0
--- /dev/null
+++ b/bin/healthchecks/worker
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+hasRunningSidekiqProcess() {
+ if [ $(bundle exec sidekiqmon processes | grep -Po '\d' | awk '{s+=$1} END {print s}') -gt 0 ]; then
+ exit 0
+ else
+ exit 1
+ fi
+}
+
+hasRunningSidekiqProcess
diff --git a/bin/migrate b/bin/migrate
new file mode 100755
index 00000000..9e78c350
--- /dev/null
+++ b/bin/migrate
@@ -0,0 +1,34 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "securerandom"
+require "sequel"
+
+env = ENV.fetch("RACK_ENV", :development)
+if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+end
+
+# Load environment
+require "bundler"
+Bundler.setup(:default, env)
+
+# Load app configuration
+require_relative "../config/bootstrap"
+Sequel.extension :migration, :core_extensions
+
+# Migrate to latest version
+version = Sequel::Migrator.run(
+ DB,
+ File.join(File.dirname(__FILE__), "../db/migrations/"),
+ use_transactions: true,
+ allow_missing_migration_files: true
+)
+DB.extension(:schema_dumper)
+File.write(
+ File.join(File.dirname(__FILE__), "../db/schema.rb"),
+ DB.dump_schema_migration(indexes: true, foreign_keys: true, same_db: true)
+)
+puts "migrated to: #{version}"
diff --git a/bin/start b/bin/start
new file mode 100755
index 00000000..791c2571
--- /dev/null
+++ b/bin/start
@@ -0,0 +1,15 @@
+#!/bin/bash
+bundle exec bin/migrate
+
+if [ "$1" = "server" ]; then
+ bundle exec puma config.ru -p $PORT
+fi
+
+if [ "$1" = "worker" ]; then
+ bundle exec sidekiq -C config/sidekiq.yml -r /usr/ebicsbox/config/sidekiq.rb
+fi
+
+if [ "$1" = "all" ]; then
+ /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf
+fi
+exit 1
diff --git a/box/adapters/fake.rb b/box/adapters/fake.rb
new file mode 100644
index 00000000..fdaf0064
--- /dev/null
+++ b/box/adapters/fake.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+module Box
+ module Adapters
+ class Fake
+ attr_accessor :setup_args
+
+ def initialize(*args)
+ self.setup_args = args
+ end
+
+ def self.setup(*)
+ new(*)
+ end
+
+ def ini_letter(_name)
+ "Here would be the INI letter you would need to sign for your bank."
+ end
+
+ def INI
+ true
+ end
+
+ def HIA
+ true
+ end
+
+ def HPB
+ true
+ end
+
+ def dump_keys
+ "{}"
+ end
+
+ def STA(_from = nil, _to = nil)
+ nil
+ end
+
+ def VMK(_from = nil, _to = nil)
+ nil
+ end
+
+ def HAC(_from = nil, _to = nil)
+ nil
+ end
+
+ def HTD
+ nil
+ end
+
+ def CD1(pain)
+ doc = Nokogiri::XML(pain)
+ trx = doc.css("Document CstmrDrctDbtInitn PmtInf DrctDbtTxInf")
+ eref = trx.css("PmtId EndToEndId").text
+ information = trx.css("RmtInf Ustrd").text
+ amount = trx.css("InstdAmt").text.delete(".").to_i
+ transaction = Transaction[eref: eref]
+ transaction.update_status("credit_received", reason: "Auto accept fake direct debit")
+
+ statement = Statement.create(
+ account_id: transaction.account_id,
+ sha: Digest::SHA2.hexdigest(SecureRandom.hex(12)).to_s,
+ date: Date.today,
+ entry_date: Date.today,
+ amount: amount,
+ sign: 0,
+ debit: true,
+ swift_code: "",
+ reference: "NOREF",
+ bank_reference: "",
+ bic: trx.css("DbtrAgt FinInstnId BIC").text,
+ iban: trx.css("DbtrAcct Id IBAN").text,
+ name: trx.css("Dbtr Nm").text,
+ information: information,
+ description: information,
+ eref: eref
+ )
+
+ Event.statement_created(statement)
+
+ ["TRX#{SecureRandom.hex(6)}", "N#{SecureRandom.hex(6)}"]
+ end
+ alias_method :CDD, :CD1
+ alias_method :CDB, :CD1
+
+ def CCT(pain)
+ doc = Nokogiri::XML(pain)
+ trx = doc.css("Document CstmrCdtTrfInitn PmtInf CdtTrfTxInf")
+ eref = trx.css("PmtId EndToEndId").text
+ desc = trx.css("RmtInf Ustrd").text
+ amount = trx.css("Amt InstdAmt").text.delete(".").to_i
+ transaction = Transaction[eref: eref]
+ transaction.update_status("debit_received", reason: "Auto accept fake credit transfers")
+
+ statement = Statement.create(
+ account_id: transaction.account_id,
+ sha: Digest::SHA2.hexdigest(SecureRandom.hex(12)).to_s,
+ date: Date.today,
+ entry_date: Date.today,
+ amount: amount,
+ sign: 1,
+ debit: false,
+ swift_code: "",
+ reference: desc,
+ bank_reference: "",
+ bic: trx.css("CdtrAgt FinInstnId BIC").text,
+ iban: trx.css("CdtrAcct Id IBAN").text,
+ name: trx.css("Cdtr Nm").text,
+ information: desc,
+ description: desc,
+ eref: eref
+ )
+
+ Event.statement_created(statement)
+ ["TRX#{SecureRandom.hex(6)}", "N#{SecureRandom.hex(6)}"]
+ end
+
+ def AZV(_dtazv)
+ ["TRX#{SecureRandom.hex(6)}", "N#{SecureRandom.hex(6)}"]
+ end
+ end
+ end
+end
diff --git a/box/adapters/file.rb b/box/adapters/file.rb
new file mode 100644
index 00000000..961aab61
--- /dev/null
+++ b/box/adapters/file.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Box
+ module Adapters
+ class File
+ def initialize(*args)
+ end
+
+ def self.setup(*)
+ new(*)
+ end
+
+ def dump_keys
+ "{}"
+ end
+
+ def ini_letter(_name)
+ "ini"
+ end
+
+ def INI
+ end
+
+ def HIA
+ end
+
+ def HPB
+ end
+
+ def STA(_from, _to)
+ ::File.read(::File.expand_path("~/sta.mt940"))
+ end
+
+ def HAC(_from, _to)
+ ::File.open(::File.expand_path("~/hac.xml"))
+ end
+
+ def CD1(_pain)
+ ["TRX#{SecureRandom.hex(6)}", "N#{SecureRandom.hex(6)}"]
+ end
+ alias_method :CDD, :CD1
+ alias_method :CDB, :CD1
+ alias_method :CCT, :CD1
+ end
+ end
+end
diff --git a/box/apis/base.rb b/box/apis/base.rb
new file mode 100644
index 00000000..1fbe1698
--- /dev/null
+++ b/box/apis/base.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "v2/base"
+
+module Box
+ module Apis
+ class Base < Grape::API
+ mount Box::Apis::V2::Base
+ end
+ end
+end
diff --git a/box/apis/healthcheck.rb b/box/apis/healthcheck.rb
new file mode 100644
index 00000000..806ac742
--- /dev/null
+++ b/box/apis/healthcheck.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "grape"
+require "grape-swagger"
+require "grape-swagger/entity"
+
+module Box
+ module Apis
+ class Healthcheck < Grape::API
+ version "v2", using: :header, vendor: "ebicsbox"
+ format :json
+
+ resource :health do
+ desc "Health check endpoint",
+ success: [{code: 200, message: "service ok"}],
+ failure: [{code: 500, message: "Internal server error"}]
+ get do
+ {status: "ok"}
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/organization_setup.rb b/box/apis/organization_setup.rb
new file mode 100644
index 00000000..e1336238
--- /dev/null
+++ b/box/apis/organization_setup.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "grape"
+require "grape-swagger"
+require "grape-swagger/entity"
+
+module Box
+ module Apis
+ class OrganizationSetup < Grape::API
+ format :json
+
+ before do
+ error! :forbidden, 403 unless Box.configuration.ui_initial_setup?
+ end
+
+ resource :setup do
+ desc "Display init page"
+ content_type :html, "text/html"
+ get do
+ content_type "text/html"
+ File.read(File.join("public", "setup", "index.html"))
+ end
+
+ desc "Overwrite default organization"
+ params do
+ requires :organization, type: String, desc: "Name of the organization"
+ requires :user_name, type: String, desc: "Name of the user"
+ end
+ post do
+ default_organization = Organization.where(name: "Primary Organization").first
+ error! :not_found, 404 unless default_organization
+ user = User.where(organization_id: default_organization.id, name: "Primary user", admin: true).first
+ error! :not_found, 404 unless user
+
+ default_organization.update(name: params[:organization])
+ user.update(name: params[:user_name])
+
+ {
+ organization: {name: default_organization.name, webhook_token: default_organization.webhook_token},
+ user: {name: user.name, access_token: user.access_token}
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/accounts.rb b/box/apis/v2/accounts.rb
new file mode 100644
index 00000000..eb468a65
--- /dev/null
+++ b/box/apis/v2/accounts.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+require_relative "../../business_processes/new_account"
+require_relative "../../entities/v2/account"
+require_relative "../../validations/unique_account"
+
+module Box
+ module Apis
+ module V2
+ class Accounts < Grape::API
+ include ApiEndpoint
+
+ content_type :html, "text/html"
+
+ rescue_from Sequel::NoMatchingRow do |_e|
+ error!({message: "Your organization does not have an account with given IBAN!"}, 404)
+ end
+
+ resource :accounts do
+ ###
+ ### GET /accounts
+ ###
+ desc "Fetch a list of accounts",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Account,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ optional :page, type: Integer, desc: "page through the results", default: 1
+ optional :per_page, type: Integer, desc: "how many results per page", values: 1..100, default: 10
+ optional :status, type: String, desc: "Filter accounts by their activation status", default: "all"
+ end
+
+ get do
+ query = Account.by_organization(current_organization).filtered(declared(params))
+ setup_pagination_header(query.count)
+ present query.paginate(declared(params)).all, with: Entities::V2::Account
+ end
+
+ ###
+ ### POST /accounts
+ ###
+ desc "Create a new account",
+ headers: AUTH_HEADERS,
+ success: Message,
+ body_name: "body",
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :name, type: String, allow_blank: false, desc: "Name of the account", documentation: {param_type: "body"}
+ requires :iban, type: String, unique_account: true, allow_blank: false, desc: "IBAN"
+ requires :bic, type: String, allow_blank: false, desc: "BIC"
+ requires :host, type: String, desc: "EBICS HOSTID as provided by financial institution"
+ requires :partner, type: String, desc: "EBICS PARTNERID as provided by financial institution"
+ requires :url, type: String, desc: "EBICS server url"
+ requires :ebics_user, type: String, desc: "EBICS ebics_user as provided by financial institution"
+ optional :descriptor, type: String, allow_blank: false, desc: "Internal descriptor of account"
+ optional :creditor_identifier, type: String, desc: "Creditor identifier required for direct debits"
+ optional :callback_url, type: String, desc: "URL to which webhooks are delivered"
+ end
+ post do
+ account = BusinessProcesses::NewAccount.create!(current_organization, current_user, declared(params, include_missing: false))
+ {
+ message: "Account created successfully. Please fetch INI letter, sign it, and submit it to your bank",
+ account: Entities::V2::Account.represent(account)
+ }
+ rescue BusinessProcesses::NewAccount::EbicsError => exception
+ log_error(exception)
+ error!({message: "Failed to setup ebics_user with your bank. Make sure your data is valid and retry!"}, 412)
+ rescue => exception
+ log_error(exception)
+ error!({message: "Failed to create account"}, 400)
+ end
+
+ ###
+ ### GET /accounts/:iban
+ ###
+
+ desc "Fetch an account",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Account,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :iban, type: String
+ end
+ get ":iban" do
+ account = Box::Account.by_organization(current_organization).first!(iban: params[:iban])
+ present account, with: Entities::V2::Account
+ end
+
+ ###
+ ### GET /accounts/:iban/ini_letter
+ ###
+
+ desc "Fetch the ini letter of an account",
+ headers: AUTH_HEADERS,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["text/html"]
+
+ params do
+ requires :iban, type: String
+ end
+ get ":iban/ini_letter" do
+ account = Box::Account.by_organization(current_organization).first!(iban: params[:iban])
+ ebics_user = account.ebics_user_for(current_user.id)
+ if ebics_user.ini_letter.nil?
+ error!({message: "EbicsUser setup not yet initiated!"}, 412)
+ else
+ content_type "text/html"
+ ebics_user.ini_letter
+ end
+ end
+
+ ###
+ ### PUT /accounts/:iban
+ ###
+
+ desc "Update an account",
+ success: Entities::V2::Account,
+ headers: AUTH_HEADERS,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ body_name: "body"
+
+ params do
+ optional :name, type: String, allow_blank: false, desc: "Name of account", documentation: {param_type: "body"}
+ optional :descriptor, type: String, allow_blank: false, desc: "Internal descriptor of account"
+ optional :creditor_identifier, type: String, desc: "Creditor identifier required for direct debits"
+ optional :callback_url, type: String, desc: "URL to which webhooks are delivered"
+ end
+ put ":iban" do
+ account = current_organization.accounts_dataset.first!(iban: params[:iban])
+ account.set(declared(params, include_missing: false))
+ if !account.modified? || account.save
+ {
+ message: "Account updated successfully.",
+ account: Entities::V2::Account.represent(account)
+ }
+ else
+ error!({message: "Failed to update account"}, 400)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/api_endpoint.rb b/box/apis/v2/api_endpoint.rb
new file mode 100644
index 00000000..41424edb
--- /dev/null
+++ b/box/apis/v2/api_endpoint.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require "active_support/concern"
+
+require_relative "../../helpers/pagination"
+require_relative "../../helpers/error_handler"
+
+module Box
+ module Apis
+ module V2
+ module ApiEndpoint
+ class Message < Grape::Entity
+ expose(:message)
+ end
+
+ DEFAULT_ERROR_RESPONSES = [
+ [400, "Invalid request", Message],
+ [401, "Not authorized to access this resource", Message],
+ [404, "Resource not found", Message],
+ [412, "EBICS account credentials not yet activated", Message],
+ [500, "Internal Server error"]
+ ].freeze
+
+ AUTH_HEADERS = {
+ "Authorization" => {description: "OAuth 2 Bearer token", required: true, default: "Bearer "}
+ }.freeze
+
+ extend ActiveSupport::Concern
+
+ included do
+ version "v2", using: :header, vendor: "ebicsbox"
+ format :json
+ helpers Helpers::Pagination
+ helpers Helpers::ErrorHandler
+
+ rescue_from :all do |exception|
+ log_error(exception)
+ error!({error: "Internal server error"}, 500, {"Content-Type" => "application/json"})
+ end
+
+ helpers do
+ def current_user
+ env["box.user"]
+ end
+
+ def current_organization
+ env["box.organization"]
+ end
+
+ def logger
+ Box.logger
+ end
+ end
+
+ before do
+ error!({message: "Unauthorized access. Please provide a valid access token!"}, 401) if current_user.nil?
+ end
+
+ rescue_from Grape::Exceptions::ValidationErrors do |e|
+ error!({
+ message: "Validation of your request's payload failed!",
+ errors: e.errors.map { |k, v| [k.first, v] }.to_h
+ }, 400)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/base.rb b/box/apis/v2/base.rb
new file mode 100644
index 00000000..e5f68ed0
--- /dev/null
+++ b/box/apis/v2/base.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "grape"
+require "grape-swagger"
+require "grape-swagger/entity"
+
+require_relative "../healthcheck"
+require_relative "../organization_setup"
+require_relative "accounts"
+require_relative "credit_transfers"
+require_relative "direct_debits"
+require_relative "service"
+require_relative "events"
+require_relative "transactions"
+require_relative "management/accounts"
+require_relative "management/ebics_users"
+require_relative "management/organizations"
+require_relative "management/users"
+require_relative "management/webhooks"
+
+module Box
+ module Apis
+ module V2
+ class Base < Grape::API
+ version "v2", using: :header, vendor: "ebicsbox"
+
+ mount Accounts
+ mount CreditTransfers
+ mount DirectDebits
+ mount Service
+ mount Transactions
+ mount Events
+ mount Management::Accounts
+ mount Management::EbicsUsers
+ mount Management::Organizations
+ mount Management::Users
+ mount Management::Webhooks
+ mount ::Box::Apis::Healthcheck
+ mount ::Box::Apis::OrganizationSetup
+
+ add_swagger_documentation \
+ doc_version: "v2",
+ mount_path: "/swagger_doc",
+ info: {
+ title: "EBICS::Box",
+ contact_name: "Railslove GmbH",
+ contact_email: "ebics-box@railslove.com",
+ contact_url: "https://www.ebicsbox.com/",
+ description: File.read("box/apis/v2/documentation.yml")
+ }
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/credit_transfers.rb b/box/apis/v2/credit_transfers.rb
new file mode 100644
index 00000000..8ebd1cd4
--- /dev/null
+++ b/box/apis/v2/credit_transfers.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+require_relative "../../entities/v2/credit_transfer"
+require_relative "../../business_processes/credit"
+require_relative "../../business_processes/foreign_credit"
+require_relative "../../validations/unique_transaction_eref"
+require_relative "../../validations/length"
+require_relative "../../errors/business_process_failure"
+
+module Box
+ module Apis
+ module V2
+ class CreditTransfers < Grape::API
+ include ApiEndpoint
+
+ resource :credit_transfers do
+ rescue_from Box::BusinessProcessFailure do |e|
+ error!({message: "Failed to initiate credit transfer.", errors: e.errors}, 400)
+ end
+
+ rescue_from Sequel::NoMatchingRow do |_e|
+ error!({message: "Your organization does not have a credit transfer with given id!"}, 404)
+ end
+
+ ###
+ ### GET /credit_transfers
+ ###
+
+ desc "Fetch a list of credit transfers",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::CreditTransfer,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ optional :iban, types: [String, [String]], desc: "IBAN of an account", coerce_with: ->(value) { value.split(",") }, documentation: {param_type: "query"}
+ optional :status, types: [String, [String]], desc: "Filter by transaction status", coerce_with: ->(value) { value.split(",") }, documentation: {param_type: "query"}
+ optional :page, type: Integer, desc: "page through the results", default: 1
+ optional :per_page, type: Integer, desc: "how many results per page", values: 1..100, default: 10
+ end
+ get do
+ query = Box::Transaction.by_organization(current_organization).credit_transfers.filtered(declared(params))
+ setup_pagination_header(query.count)
+ present query.paginate(declared(params)).all, with: Entities::V2::CreditTransfer
+ end
+
+ ###
+ ### POST /credit_transfers
+ ###
+
+ desc "Create a credit transfer",
+ headers: AUTH_HEADERS,
+ success: Message,
+ body_name: "body",
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ detail: <<-USAGE.strip_heredoc
+ Creating a credit by parameter should be the preferred way for low-volume transactions
+ esp. for use cases where the PAIN XML isn't generated before.
+
+ Once validated, transactions are transmitted asynchronously to the banking system. Errors
+ that happen eventually are delivered via Webhooks.
+ USAGE
+
+ params do
+ requires :account, type: String, desc: "the account to use", documentation: {param_type: "body"}
+ requires :name, type: String, desc: "the customers name"
+
+ optional :currency, type: String, desc: "currency of the transfer", length: 3, regexp: /[A-Z]{3}/, default: "EUR"
+ requires :iban, type: String, desc: "the customers account"
+
+ given currency: ->(val) { val != "EUR" } do
+ requires :bic, type: String, desc: "the customers bic", allow_blank: false
+ requires :country_code, type: String, desc: "the customers country", allow_blank: false
+ optional :fee_handling, type: Symbol, values: %i[split sender receiver], default: :split
+ end
+
+ given currency: ->(val) { val == "EUR" } do
+ optional :urgent, type: Boolean, desc: "requested execution date", default: false
+ end
+
+ requires :end_to_end_reference, type: String, desc: "unique end to end reference", unique_transaction_eref: true, length_transaction_eref: true
+
+ requires :amount_in_cents, type: Integer, desc: "amount to credit (charged in cents)", values: 1..1_200_000_000
+ optional :reference, type: String, length: 140, desc: "description of the transaction (max. 140 char)"
+ optional :execution_date, type: Date, desc: "requested execution date", default: -> { Date.today }
+ end
+
+ post do
+ account = current_organization.find_account!(params[:account])
+
+ # dirty workaround for grape issue with "same dependant param given"
+ # TL;DR: declared params contain keys even though the condition for them is not met
+ # https://github.com/ruby-grape/grape/issues/1885
+ sanitized_params = declared(params)
+
+ if sanitized_params[:currency] == "EUR"
+ sanitized_params.reject! { |k, _v| k.in? %w[big country_code fee_handling] } # still related to workaround
+ BusinessProcesses::Credit.v2_create!(current_user, account, sanitized_params)
+ else
+ sanitized_params.reject! { |k, _v| k.in? %w[urgent] } # still related to workaround
+ BusinessProcesses::ForeignCredit.v2_create!(current_user, account, sanitized_params)
+ end
+
+ {message: "Credit transfer has been initiated successfully!"}
+ end
+
+ ###
+ ### GET /credit_transfers/:id
+ ###
+
+ desc "Fetch a credit transfer",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::CreditTransfer,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+ params do
+ requires :id, type: String
+ end
+ get ":id" do
+ if Box::Transaction::ID_REGEX.match?(params[:id].to_s)
+ credit_transfer = Box::Transaction.by_organization(current_organization).credit_transfers.first!(public_id: params[:id])
+ present credit_transfer, with: Entities::V2::CreditTransfer
+ else
+ raise Sequel::NoMatchingRow
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/direct_debits.rb b/box/apis/v2/direct_debits.rb
new file mode 100644
index 00000000..7dbd31a5
--- /dev/null
+++ b/box/apis/v2/direct_debits.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+require_relative "../../entities/v2/direct_debit"
+require_relative "../../business_processes/direct_debit"
+require_relative "../../validations/unique_transaction_eref"
+require_relative "../../validations/length"
+require_relative "../../errors/business_process_failure"
+
+module Box
+ module Apis
+ module V2
+ class DirectDebits < Grape::API
+ include ApiEndpoint
+
+ resource :direct_debits do
+ rescue_from Box::BusinessProcessFailure do |e|
+ error!({message: "Failed to initiate direct debit.", errors: e.errors}, 400)
+ end
+
+ rescue_from Sequel::NoMatchingRow do |_e|
+ error!({message: "Your organization does not have a credit transfer with given id!"}, 404)
+ end
+
+ ###
+ ### GET /direct_debits
+ ###
+
+ desc "Fetch a list of direct debits",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::DirectDebit,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ optional :iban, types: [String, [String]], desc: "IBANs of account to filter", documentation: {param_type: "query"}
+ optional :status, types: [String, [String]], desc: "Filter by transaction status", coerce_with: ->(value) { value.split(",") }, documentation: {param_type: "query"}
+ optional :page, type: Integer, desc: "page through the results", default: 1
+ optional :per_page, type: Integer, desc: "how many results per page", values: 1..100, default: 10
+ end
+ get do
+ query = Box::Transaction.by_organization(current_organization).direct_debits.filtered(declared(params))
+ setup_pagination_header(query.count)
+ present query.paginate(declared(params)).all, with: Entities::V2::DirectDebit
+ end
+
+ ###
+ ### POST /direct_debits
+ ###
+
+ desc "Create a direct debit",
+ headers: AUTH_HEADERS,
+ success: Message,
+ body_name: "body",
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ detail: <<-USAGE.strip_heredoc
+ Creating a debit by parameter should be the preferred way for low-volume transactions esp. for use
+ cases where the PAIN XML isn’t generated before. Transactions can be transmitted either as CD1
+ or CDD depending on the order types your bank is offering you, the order_type parameter
+ lets you choose among them.
+
+ sequence_types:
+ - OOFF - one-off debit
+ - FRST - first debit
+ - RCUR - recurring debit
+ - FNAL - final debit
+
+ Once validated, transactions are transmitted asynchronously to the banking system.
+ Errors that happen eventually are delivered via Webhooks
+ USAGE
+
+ params do
+ requires :account, type: String, desc: "the iban of the account to use", documentation: {param_type: "body"}
+ requires :name, type: String, desc: "the customers name"
+ requires :iban, type: String, desc: "the customers iban"
+ requires :amount_in_cents, type: Integer, desc: "amount to debit (in cents)", values: 1..1_200_000_000
+ requires :end_to_end_reference, type: String, desc: "unique end to end reference", unique_transaction_eref: true
+ requires :mandate_id, type: String, desc: "ID of the SEPA mandate (max. 35 char)"
+ requires :mandate_signature_date, type: Integer, desc: "when the mandate was signed by the customer"
+ optional :bic, type: String, desc: "the customers bic"
+ optional :reference, type: String, length: 140, desc: "description of the transaction (max. 140 char)"
+ optional :instrument, type: String, desc: "", values: %w[CORE COR1 B2B], default: "CORE"
+ optional :sequence_type, type: String, desc: "", values: %w[FRST RCUR OOFF FNAL], default: "FRST"
+ optional :instruction, type: String, desc: "instruction identification, will not be submitted to the debtor"
+ optional :execution_date, type: Date, desc: "requested execution date", default: -> { 2.days.from_now }
+ end
+
+ post do
+ account = current_organization.find_account!(params[:account])
+ BusinessProcesses::DirectDebit.v2_create!(current_user, account, declared(params))
+ {message: "Direct debit has been initiated successfully!"}
+ end
+
+ ###
+ ### GET /direct_debits/:id
+ ###
+
+ desc "Fetch a direct debit",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::DirectDebit,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+ params do
+ requires :id, type: String
+ end
+ get ":id" do
+ if Box::Transaction::ID_REGEX.match?(params[:id].to_s)
+ direct_debit = Box::Transaction.by_organization(current_organization).direct_debits.first!(public_id: params[:id])
+ present direct_debit, with: Entities::V2::DirectDebit
+ else
+ raise Sequel::NoMatchingRow
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/documentation.yml b/box/apis/v2/documentation.yml
new file mode 100644
index 00000000..f3f879ee
--- /dev/null
+++ b/box/apis/v2/documentation.yml
@@ -0,0 +1,172 @@
+A modern API for bank accounts. Fully automatize processing of
+incoming and outgoing money transactions. It enables high-level
+access to some EBCIS features and wraps them with further
+functionality.
+
+## Clarification of terms
+### EREF
+The most important building block of the EBICS::BOX is the EREF aka
+"End to End ID" or "End to End Reference". It is a universal
+identifier that will be used to recognize transactions throughout
+their whole lifecycle. The maximum length is 35 characters.
+
+### Matchmaking
+Every time a new "outgoing" transaction is created (debit or credit)
+the EREF will be stored on the internal watchlist, whenever we're
+seeing these IDs in new transactions you'll get notified via Webhooks.
+The most used use case will be to identify chargebacks or detect that
+the money was actually transferred from your bank account.
+
+### Media Types
+All actions require and return JSON formatted data. Timestamps are
+always formatted using ISO 8601. All data is UTF-8 encoded.
+
+ `Content-Type: application/json`
+
+### Webhooks
+The account callback url can be defined either through the api.
+When the callback URL is set, webhook delivery is enabled.
+
+If the deliver of a webhooks fails for any reason, it will be
+attempted again up to 20 times, exponentially delaying the execution.
+
+After that, to reset the retry count, `POST: /webhooks/reset` can be
+used. See below.
+
+Following are all supported callbacks with parameters. In
+parenthesis is a short explanation for the non obvious parameters,
+fixed values are written "as strings".
+
+Account Created:
+```
+{
+ action: "account_created",
+ triggered_at (time of event),
+ account_id,
+ account: {
+ id, iban, bic, creditor_identifier, name, url, host, partner,
+ callback_url, mode, bankname, organization_id
+ }
+}
+```
+
+Debit Created:
+```
+{
+ action: "debit_created",
+ triggered_at (time of event),
+ id (public id),
+ account_id,
+ transaction: {
+ id, eref, type, status, ebics_order_id, ebics_transaction_id
+ }
+}
+```
+
+Credit Created:
+```
+{
+ action: "credit_created",
+ triggered_at (time of event),
+ id (public id),
+ account_id,
+ transaction: {
+ id, eref, type, status, ebics_order_id, ebics_transaction_id
+ }
+}
+```
+
+Statement Created:
+```
+{
+ action: "statement_created",
+ triggered_at (time of event),
+ account_id,
+ statement: {
+ id, account (iban), name, bic, iban, type, amount (cents), date,
+ remittance_information
+ }
+}
+```
+
+Ebics User Activated:
+```
+{
+ action: "ebics_user_activated",
+ triggered_at (time of event),
+ ebics_user_id,
+ account_id,
+ user_id,
+ ebics_user (remote user id),
+ signature_class
+}
+```
+
+Credit Status Changed:
+```
+{
+ action: "credit_status_changed",
+ triggered_at (time of event),
+ id,
+ account_id,
+ transaction: {
+ id, eref, type, status, ebics_order_id, ebics_transaction_id
+ }
+}
+```
+
+Debit Status Changed:
+```
+{
+ action: "debit_status_changed",
+ triggered_at (time of event),
+ id,
+ account_id,
+ transaction: {
+ id, eref, type, status, ebics_order_id, ebics_transaction_id
+ }
+}
+```
+
+
+### Errors
+Due to its REST nature, the API returns proper http error
+codes. Usually status codes in the 2xx range indicate a successful
+operation, 4xx indicates an error resulting from the provided
+attributes. And errors in the 5xx range indicate a problem in the
+EBICS::BOX. The JSON object returned looks like the following:
+```
+ {
+ "message": "Human readable description of the error",
+ "errors": {
+ "((field))": [ "some error", "another error" ]
+ }
+ }
+```
+
+### Versioning
+If not specified otherwise, the API will always use the most recent
+version available. In order to use a specific version, clients need
+to request it via header:
+
+`Accept: application/vnd.ebicsbox-v2+json`
+
+Please note that we expect applications to be flexible enough to
+accept additional fields without a major version change. Breaking
+changes, like changed behavior and removal or renaming of fields
+will always result in a version number bump.
+
+### Prerequisites
+To use every feature that is offered by the EBICS::BOX you should
+make sure that your bank supports and offers the respective order
+types.
+
+ - Transaction Import: `STA` or `C53`
+ - Usage protocols: `HAC`
+ - Credits: `CCT`
+ - Debits: `CDD`
+
+Furthermore to process direct debits you'll have to obtain a Creditor
+Identification Number from the
+[Bundesbank](http://www.bundesbank.de/Navigation/DE/Aufgaben/Unbarer_Zahlungsverkehr/SEPA/Glaeubiger_Identifikationsnummer/glaeubiger_identifikationsnummer.html)
+and sign some additional contracts with your bank.
diff --git a/box/apis/v2/events.rb b/box/apis/v2/events.rb
new file mode 100644
index 00000000..b6b13b9a
--- /dev/null
+++ b/box/apis/v2/events.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+require_relative "../../models/event"
+require_relative "../../entities/v2/event"
+
+module Box
+ module Apis
+ module V2
+ class Events < Grape::API
+ include ApiEndpoint
+
+ resource :events do
+ desc "List of all events",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Event,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ detail: <<-USAGE.strip_heredoc
+ Paginated list of all events which occured on an organization. These are not account
+ specific. Each event will trigger a webhook delivery as long as a webhook endpoint
+ is specified for an account. To get more data on webhook deliveries, please check an
+ events details by following the self source in its _links section.
+ USAGE
+
+ params do
+ optional :page, type: Integer, desc: "page through the results", default: 1
+ optional :per_page, type: Integer, desc: "how many results per page", values: 1..100, default: 10
+ end
+
+ get do
+ record_count = Box::Event.by_organization(current_organization).count
+ events = Box::Event.by_organization(current_organization)
+ .paginated(params[:page], params[:per_page])
+ .reverse_order(:triggered_at)
+ .all
+
+ setup_pagination_header(record_count)
+ present events, with: Entities::V2::Event
+ end
+
+ desc "Details for an event",
+ name: "event_details",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Event,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ detail: <<-USAGE.strip_heredoc
+ Get details on every triggered event. In case of a webhook delivery, all attempts
+ are listed. For each attempt we store data on its response and errors if any are
+ encountered. After 20 attempts, the system will stop to any retries.
+ USAGE
+
+ params do
+ requires :id, type: String
+ end
+ get ":id" do
+ event = Box::Event.by_organization(current_organization).first!(public_id: params[:id])
+ present event, with: Entities::V2::Event, type: "full"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/management/accounts.rb b/box/apis/v2/management/accounts.rb
new file mode 100644
index 00000000..a716554c
--- /dev/null
+++ b/box/apis/v2/management/accounts.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../api_endpoint"
+
+# Validations
+require_relative "../../../validations/unique_account"
+require_relative "../../../validations/active_account"
+
+# Helpers
+require_relative "../../../helpers/default"
+
+# Entities
+require_relative "../../../entities/management_account"
+
+module Box
+ module Apis
+ module V2
+ module Management
+ class Accounts < Grape::API
+ include ApiEndpoint
+ content_type :html, "text/html"
+
+ namespace :management do
+ before do
+ unless env["box.admin"]
+ error!({message: "Unauthorized access. Please provide a valid organization management token!"}, 401)
+ end
+ end
+
+ desc "Service", hidden: true
+ get "/" do
+ {message: "not yet implemented"}
+ end
+
+ resource :accounts do
+ ###
+ ### GET /management/accounts
+ ###
+ desc "Retrieve a list of all onboarded accounts",
+ tags: ["account management"],
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::ManagementAccount,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ get do
+ accounts = current_organization.accounts_dataset.order(:name).all
+ present accounts, with: Entities::ManagementAccount
+ end
+
+ ###
+ ### GET /management/accounts/DExx
+ ###
+ desc "Retrieve a single account by its IBAN",
+ tags: ["account management"],
+ headers: AUTH_HEADERS,
+ success: Entities::ManagementAccount,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :iban, type: String
+ end
+ get ":iban" do
+ account = current_organization.accounts_dataset.first!(iban: params[:iban])
+ present account, with: Entities::ManagementAccount, type: "full"
+ rescue Sequel::NoMatchingRow
+ error!({message: "Your organization does not have an account with given IBAN!"}, 404)
+ end
+
+ ###
+ ### POST /management/accounts
+ ###
+ desc "Create a new account",
+ tags: ["account management"],
+ body_name: "body",
+ headers: AUTH_HEADERS,
+ success: Entities::ManagementAccount,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :name, type: String, unique_account: true, allow_blank: false, desc: "Internal description of account", documentation: {param_type: "body"}
+ requires :iban, type: String, unique_account: true, allow_blank: false, desc: "IBAN"
+ requires :bic, type: String, allow_blank: false, desc: "BIC"
+ optional :bankname, type: String, desc: "Name of bank (for internal purposes)"
+ optional :creditor_identifier, type: String, desc: "creditor_identifier"
+ optional :callback_url, type: String, desc: "callback_url"
+ optional :host, type: String, desc: "host"
+ optional :partner, type: String, desc: "partner"
+ optional :url, type: String, desc: "url"
+ optional :mode, type: String, desc: "mode"
+ end
+ post do
+ account = current_organization.add_account(declared(params))
+ if account
+ Event.account_created(account)
+ present account, with: Entities::ManagementAccount
+ else
+ error!({message: "Failed to create account"}, 400)
+ end
+ end
+
+ ###
+ ### PUT /management/accounts/DExx
+ ###
+ desc "Update an existing account",
+ tags: ["account management"],
+ headers: AUTH_HEADERS,
+ success: Entities::ManagementAccount,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ optional :name, type: String, unique_account: true, allow_blank: false, desc: "Internal description of account", documentation: {param_type: "body"}
+ optional :bankname, type: String, desc: "Name of bank (for internal purposes)"
+ optional :creditor_identifier, type: String, desc: "creditor_identifier"
+ optional :callback_url, type: String, desc: "callback_url"
+ optional :host, type: String, desc: "host"
+ optional :partner, type: String, desc: "partner"
+ optional :url, type: String, desc: "url"
+ end
+ put ":iban" do
+ account = current_organization.accounts_dataset.first!(iban: params[:iban])
+ account.set(declared(params))
+ if !account.modified? || account.save
+ present account, with: Entities::ManagementAccount
+ else
+ error!({message: "Failed to update account"}, 400)
+ end
+ rescue Sequel::NoMatchingRow
+ error!({message: "Your organization does not have an account with given IBAN!"}, 404)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/management/ebics_users.rb b/box/apis/v2/management/ebics_users.rb
new file mode 100644
index 00000000..b666e580
--- /dev/null
+++ b/box/apis/v2/management/ebics_users.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../api_endpoint"
+
+# Validations
+require_relative "../../../validations/unique_ebics_user"
+
+# Helpers
+require_relative "../../../helpers/default"
+
+# Entities
+require_relative "../../../entities/ebics_user"
+
+module Box
+ module Apis
+ module V2
+ module Management
+ class EbicsUsers < Grape::API
+ include ApiEndpoint
+ content_type :html, "text/html"
+
+ namespace "/management/accounts/:iban" do
+ params do
+ requires :iban, type: String, desc: "IBAN for the account"
+ end
+ before do
+ unless env["box.admin"]
+ error!({message: "Unauthorized access. Please provide a valid organization management token!"}, 401)
+ end
+
+ @account = current_organization.accounts_dataset.first!(iban: params[:iban])
+ rescue Sequel::NoMatchingRow
+ error!({message: "Your organization does not have an account with given IBAN!"}, 404)
+ end
+
+ desc "Service", hidden: true
+ get "/" do
+ {message: "not yet implemented"}
+ end
+
+ resource :ebics_users do
+ ###
+ ### GET /management/accounts/DExx/ebics_users/1/ini_letter
+ ###
+ desc "Retrieve a list of all ebics_users for given account",
+ tags: ["ebics_user management"],
+ headers: AUTH_HEADERS,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :id, type: Integer, desc: "ID of the ebics_user"
+ end
+ get ":id/ini_letter" do
+ ebics_user = @account.ebics_users_dataset.first!(Sequel.qualify(:ebics_users, :id) => params[:id])
+ if ebics_user.ini_letter.nil?
+ error!({message: "EbicsUser setup not yet initiated!"}, 412)
+ else
+ content_type "text/html"
+ ebics_user.ini_letter
+ end
+ end
+
+ params do
+ requires :id, type: Integer, desc: "ID of the ebics_user"
+ end
+ post ":id/refresh_bank_keys" do
+ ebics_user = @account.ebics_users_dataset.first!(Sequel.qualify(:ebics_users, :id) => params[:id])
+ if ebics_user.refresh_bank_keys!
+ present ebics_user, with: Entities::EbicsUser
+ else
+ error!({message: "Bank keys could not be refreshed!"}, 500)
+ end
+ end
+
+ ###
+ ### GET /management/accounts/DExx/ebics_users
+ ###
+ desc "Retrieve a list of all ebics_users for given account",
+ tags: ["ebics_user management"],
+ headers: AUTH_HEADERS,
+ success: Entities::EbicsUser,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ end
+ get do
+ present @account.ebics_users, with: Entities::EbicsUser
+ end
+
+ ###
+ ### POST /management/accounts/DExx/ebics_users
+ ###
+ desc "Add a ebics_user to given account",
+ tags: ["ebics_user management"],
+ body_name: "body",
+ headers: AUTH_HEADERS,
+ success: Entities::EbicsUser,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :user_id, type: Integer, desc: "Internal user identifier to associate the ebics_user with", documentation: {param_type: "body"}
+ requires :ebics_user, type: String, unique_ebics_user: true, desc: "EBICS user to represent"
+ end
+ post do
+ ebics_user = @account.add_ebics_user(
+ user_id: declared(params)[:user_id],
+ remote_user_id: declared(params)[:ebics_user],
+ partner: @account.partner
+ )
+ error!({message: "Failed to create ebics_user"}, 400) unless ebics_user
+
+ if ebics_user.setup!(@account)
+ present ebics_user, with: Entities::EbicsUser
+ else
+ ebics_user.destroy
+ error!({message: "Failed to setup ebics_user. Make sure your data is valid and retry!"}, 412)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/management/organizations.rb b/box/apis/v2/management/organizations.rb
new file mode 100644
index 00000000..814e4c0a
--- /dev/null
+++ b/box/apis/v2/management/organizations.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../api_endpoint"
+
+# Helpers
+require_relative "../../../helpers/default"
+
+# Entities
+require_relative "../../../entities/v2/organization"
+
+module Box
+ module Apis
+ module V2
+ module Management
+ class Organizations < Grape::API
+ include ApiEndpoint
+ content_type :html, "text/html"
+
+ namespace :management do
+ before do
+ unless env["box.admin"]
+ error!({message: "Unauthorized access. Please provide a valid organization management token!"}, 401)
+ end
+ end
+
+ desc "Service", hidden: true
+ get "/" do
+ {message: "not yet implemented"}
+ end
+
+ resource :organizations do
+ # create
+ desc "Create a new organization",
+ tags: ["organization management"],
+ body_name: "body",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Organization,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :name, type: String, allow_blank: false, desc: "The organization's display name"
+ requires :user, type: Hash do
+ requires :name, type: String, allow_blank: false, desc: "The user's display name"
+ optional :access_token, type: String, desc: "Set a custom access token"
+ end
+ optional :webhook_token, type: String, desc: "Token to sign organization's webhook payloads"
+ end
+ post do
+ DB.transaction do
+ organization = Organization.register(declared(params).except(:user))
+ organization.add_user(declared(params)[:user].merge(admin: true))
+ present organization, with: Entities::V2::Organization
+ end
+ rescue => exception
+ log_error(exception, logger_prefix: "[Registration]")
+ error!({message: "Failed to create organization!"}, 400)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/management/users.rb b/box/apis/v2/management/users.rb
new file mode 100644
index 00000000..87a35e48
--- /dev/null
+++ b/box/apis/v2/management/users.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../api_endpoint"
+
+# Helpers
+require_relative "../../../helpers/default"
+
+# Entities
+require_relative "../../../entities/user"
+
+module Box
+ module Apis
+ module V2
+ module Management
+ class Users < Grape::API
+ include ApiEndpoint
+ content_type :html, "text/html"
+
+ namespace :management do
+ before do
+ unless env["box.admin"]
+ error!({message: "Unauthorized access. Please provide a valid organization management token!"}, 401)
+ end
+ end
+
+ desc "Service", hidden: true
+ get "/" do
+ {message: "not yet implemented"}
+ end
+
+ resource :users do
+ # index
+ desc "Retrieve a list of all users",
+ tags: ["user management"],
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::User,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ get do
+ users = current_organization.users_dataset.order(:name).all
+ present users, with: Entities::User, type: "full", include_admin_state: true
+ end
+
+ # show
+ desc "Retrieve a single user by its identifier",
+ tags: ["user management"],
+ headers: AUTH_HEADERS,
+ success: Entities::User,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :id, type: Integer, desc: "ID of the user"
+ end
+
+ get ":id" do
+ user = current_organization.users_dataset.first!(id: params[:id])
+ present user, with: Entities::User, type: "full", include_token: true, include_admin_state: true
+ rescue Sequel::NoMatchingRow
+ error!({message: "User not found"}, 404)
+ end
+
+ # create
+ desc "Create a new user instance",
+ tags: ["user management"],
+ body_name: "body",
+ headers: AUTH_HEADERS,
+ success: Entities::User,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :name, type: String, desc: "The user's display name", documentation: {param_type: "body"}
+ optional :access_token, type: String, desc: "Set a custom access token"
+ end
+
+ post do
+ user = current_organization.add_user(declared(params))
+ if user
+ present user, with: Entities::User, include_token: true
+ else
+ error!({message: "Failed to create user"}, 400)
+ end
+ end
+
+ # delete
+ desc "Deletes a user instance",
+ tags: ["user management"],
+ body_name: "body",
+ headers: AUTH_HEADERS,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :id, type: Integer, desc: "The user's id", documentation: {param_type: "body"}
+ end
+
+ delete ":id" do
+ current_organization.users_dataset.first!(id: declared(params)["id"])&.destroy
+ status 204
+ rescue Sequel::NoMatchingRow
+ error!({message: "User not found"}, 404)
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/management/webhooks.rb b/box/apis/v2/management/webhooks.rb
new file mode 100644
index 00000000..ee04bdb0
--- /dev/null
+++ b/box/apis/v2/management/webhooks.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../api_endpoint"
+require_relative "../../../models/event"
+require_relative "../../../entities/v2/event"
+
+module Box
+ module Apis
+ module V2
+ module Management
+ class Webhooks < Grape::API
+ include ApiEndpoint
+
+ namespace :management do
+ resource :webhooks do
+ desc "Reset the retry count to 0 for pending and failed events",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Event,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"],
+ detail: <<~USAGE
+ The webhooks will only be retried 20 times, after that a
+ consuming application can reset the webhook status here to
+ receive webhooks which have not been received yet dues to
+ e.g. an outage
+ USAGE
+ post "reset" do
+ events = Box::Event
+ .by_organization(current_organization)
+ .exclude(webhook_status: "success")
+ .all
+
+ events.each(&:reset_webhook_delivery)
+
+ present events, with: Entities::V2::Event
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/service.rb b/box/apis/v2/service.rb
new file mode 100644
index 00000000..3be4a71d
--- /dev/null
+++ b/box/apis/v2/service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+
+module Box
+ module Apis
+ module V2
+ class Service < Grape::API
+ include ApiEndpoint
+
+ desc "Service",
+ hidden: true
+
+ get do
+ {
+ version: "v2"
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/box/apis/v2/transactions.rb b/box/apis/v2/transactions.rb
new file mode 100644
index 00000000..7792b64a
--- /dev/null
+++ b/box/apis/v2/transactions.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "api_endpoint"
+require_relative "../../entities/v2/transaction"
+
+module Box
+ module Apis
+ module V2
+ class Transactions < Grape::API
+ include ApiEndpoint
+
+ resource :transactions do
+ desc "Fetch a list of transactions",
+ is_array: true,
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Transaction,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ optional :page, type: Integer, desc: "page through the results", default: 1
+ optional :per_page, type: Integer, desc: "how many results per page", values: 1..100, default: 10
+ optional :iban, types: [String, [String]], desc: "IBAN of an account", coerce_with: ->(value) { value.split(",") }, documentation: {param_type: "query"}
+ optional :type, type: String, desc: "Type of statement", values: %w[credit debit]
+ optional :from, type: Date, desc: "Date from which on to filter the results"
+ optional :end_to_end_reference, type: String, desc: "Filter by end to end reference"
+ optional :to, type: Date, desc: "Date to which filter results"
+ end
+
+ get do
+ query = Box::Statement.by_organization(current_organization).filtered(declared(params))
+ setup_pagination_header(query.count)
+ present query.paginate(declared(params)).all, with: Entities::V2::Transaction
+ end
+
+ # show
+ desc "Fetch details of a transaction",
+ headers: AUTH_HEADERS,
+ success: Entities::V2::Transaction,
+ failure: DEFAULT_ERROR_RESPONSES,
+ produces: ["application/vnd.ebicsbox-v2+json"]
+
+ params do
+ requires :id, type: String, desc: "public-ID of the transaction"
+ end
+
+ get ":id" do
+ statement = Box::Statement.by_organization(current_organization).first!(public_id: params[:id])
+ present statement, with: Entities::V2::Transaction
+ rescue Sequel::NoMatchingRow
+ error!({message: "Transaction not found"}, 404)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/business_processes/credit.rb b/box/business_processes/credit.rb
new file mode 100644
index 00000000..cc14824b
--- /dev/null
+++ b/box/business_processes/credit.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "base64"
+require "securerandom"
+require "sepa_king"
+
+require_relative "../errors/business_process_failure"
+require_relative "../queue"
+
+module Box
+ module BusinessProcesses
+ class Credit
+ def self.create!(account, params, user)
+ sct = SEPA::CreditTransfer.new(account.credit_pain_attributes_hash).tap do |credit|
+ credit.message_identification = "EBICS-BOX/#{SecureRandom.hex(11).upcase}"
+ credit.add_transaction(
+ name: params[:name],
+ bic: params[:bic],
+ iban: params[:iban],
+ amount: params[:amount] / 100.0,
+ reference: params[:eref],
+ remittance_information: params[:remittance_information],
+ requested_date: Time.at(params[:requested_date]).to_date,
+ batch_booking: false,
+ service_level: params[:service_level]
+ )
+ end
+
+ if sct.valid?
+ Queue.execute_credit(
+ account_id: account.id,
+ user_id: user.id,
+ payload: Base64.strict_encode64(sct.to_xml("pain.001.001.03")),
+ eref: params[:eref],
+ currency: "EUR",
+ amount: params[:amount],
+ metadata: {
+ **params.slice(:name, :iban, :bic, :reference),
+ execution_date: params[:execution_date]&.iso8601
+ }
+ )
+ else
+ raise Box::BusinessProcessFailure, sct.errors
+ end
+ rescue ArgumentError => e
+ # TODO: Will be fixed upstream in the sepa_king gem by us
+ raise Box::BusinessProcessFailure.new({base: e.message}, "Invalid data")
+ end
+
+ def self.v2_create!(user, account, params)
+ # EBICS requires a unix timestamp
+ params[:requested_date] = params[:execution_date].to_time.to_i
+
+ # Transform a few params
+ params[:amount] = params[:amount_in_cents]
+ params[:eref] = params[:end_to_end_reference]
+ params[:remittance_information] = params[:reference]
+
+ # Set urgent flag or fall back to SEPA
+ params[:service_level] = params[:urgent] ? "URGP" : "SEPA"
+
+ create!(account, params, user)
+ end
+ end
+ end
+end
diff --git a/box/business_processes/direct_debit.rb b/box/business_processes/direct_debit.rb
new file mode 100644
index 00000000..83a58b25
--- /dev/null
+++ b/box/business_processes/direct_debit.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "base64"
+require "securerandom"
+require "sepa_king"
+
+require_relative "../errors/business_process_failure"
+require_relative "../queue"
+
+module Box
+ module BusinessProcesses
+ class DirectDebit
+ def self.create!(account, params, user)
+ sdd = SEPA::DirectDebit.new(account.pain_attributes_hash).tap do |debit|
+ debit.message_identification = "EBICS-BOX/#{SecureRandom.hex(11).upcase}"
+ debit.add_transaction(
+ name: params[:name],
+ bic: params[:bic],
+ iban: params[:iban],
+ amount: params[:amount] / 100.0,
+ instruction: params[:instruction],
+ mandate_id: params[:mandate_id],
+ mandate_date_of_signature: Time.at(params[:mandate_signature_date]).to_date,
+ local_instrument: params[:instrument],
+ sequence_type: params[:sequence_type],
+ reference: params[:eref],
+ remittance_information: params[:remittance_information],
+ requested_date: Time.at(params[:requested_date]).to_date,
+ batch_booking: true
+ )
+ end
+
+ if sdd.valid?
+ Queue.execute_debit(
+ account_id: account.id,
+ user_id: user.id,
+ payload: Base64.strict_encode64(sdd.to_xml("pain.008.001.02")),
+ amount: params[:amount],
+ eref: params[:eref],
+ instrument: params[:instrument]
+ )
+ else
+ raise Box::BusinessProcessFailure, sdd.errors
+ end
+ rescue ArgumentError => e
+ # TODO: Will be fixed upstream in the sepa_king gem by us
+ raise Box::BusinessProcessFailure.new({base: e.message}, "Invalid data")
+ end
+
+ def self.v2_create!(user, account, params)
+ # EBICS requires a unix timestamp
+ params[:requested_date] = params[:execution_date].to_time.to_i
+
+ # Transform a few params
+ params[:amount] = params[:amount_in_cents]
+ params[:eref] = params[:end_to_end_reference]
+ params[:remittance_information] = params[:reference]
+
+ # Execute v1 method
+ create!(account, params, user)
+ end
+ end
+ end
+end
diff --git a/box/business_processes/foreign_credit.rb b/box/business_processes/foreign_credit.rb
new file mode 100644
index 00000000..2c00c9f6
--- /dev/null
+++ b/box/business_processes/foreign_credit.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require "king_dtaus"
+require "ostruct"
+require_relative "../errors/business_process_failure"
+
+module Box
+ module BusinessProcesses
+ class ForeignCredit
+ class Payload < OpenStruct
+ def sender
+ KingDta::Account.new(
+ owner_name: account.name,
+ bank_number: account.bank_number,
+ owner_country_code: account.bank_country_code,
+ bank_account_number: account.bank_account_number
+ )
+ end
+
+ def receiver
+ KingDta::Account.new(
+ bank_bic: params[:bic],
+ owner_name: params[:name],
+ owner_country_code: params[:country_code],
+ ** account_number
+ )
+ end
+
+ def account_number
+ if /[A-Z]{2}/.match?(params[:iban])
+ {bank_iban: params[:iban]}
+ else
+ {bank_account_number: params[:iban]}
+ end
+ end
+
+ def amount
+ params[:amount] / 100.0
+ end
+
+ def fee_handling
+ {
+ split: "00",
+ sender: "01",
+ receiver: "02"
+ }[params[:fee_handling]]
+ end
+
+ def booking
+ KingDta::Booking.new(receiver, amount, params[:eref], nil, params[:currency]).tap do |booking|
+ booking.payment_type = "00"
+ booking.charge_bearer_code = fee_handling
+ end
+ end
+
+ def create
+ azv = KingDta::Dtazv.new(params[:execution_date])
+ azv.account = sender
+ azv.add(booking)
+
+ azv.create
+ end
+ end
+
+ def self.v2_create!(user, account, params)
+ params[:requested_date] = params[:execution_date].to_time.to_i
+
+ # Transform a few params
+ params[:amount] = params[:amount_in_cents]
+ params[:eref] = params[:end_to_end_reference]
+ params[:remittance_information] = params[:reference]
+
+ payload = Payload.new(account: account, params: params)
+
+ Queue.execute_credit(
+ account_id: account.id,
+ user_id: user.id,
+ payload: Base64.strict_encode64(payload.create),
+ eref: params[:eref],
+ currency: params[:currency],
+ amount: params[:amount],
+ metadata: {
+ **params.slice(:name, :iban, :bic, :reference, :country_code),
+ execution_date: params[:execution_date]&.iso8601,
+ fee_handling: params[:fee_handling]&.to_s
+ }
+ )
+ rescue ArgumentError => e
+ # TODO: Will be fixed upstream in the sepa_king gem by us
+ raise Box::BusinessProcessFailure.new({base: e.message}, "Invalid data")
+ end
+ end
+ end
+end
diff --git a/box/business_processes/import_bank_statement.rb b/box/business_processes/import_bank_statement.rb
new file mode 100644
index 00000000..7f2f7c5f
--- /dev/null
+++ b/box/business_processes/import_bank_statement.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require "cmxl"
+
+require_relative "../models/account"
+require_relative "../models/bank_statement"
+require_relative "../../lib/checksum_generator"
+require_relative "../../lib/data_mapping/statement_factory"
+
+# more general matching regex that covers both newlines and newlines with dashes
+Cmxl.config[:statement_separator] = /(\n-?)(?=:20)/m
+
+module Box
+ module BusinessProcesses
+ class ImportBankStatement
+ InvalidInput = Class.new(ArgumentError)
+
+ def self.import_all_from_mt940(raw_mt940, account)
+ Cmxl.parse(raw_mt940).map do |raw_bank_statement|
+ process(raw_bank_statement, account)
+ rescue InvalidInput => _ex
+ nil # ignore
+ end.compact
+ end
+
+ # There are cases where we only have the raw mt940 file.
+ def self.from_mt940(raw_mt940, account)
+ mt940_chunk = Cmxl.parse(raw_mt940).first
+ process(mt940_chunk, account)
+ end
+
+ # In case we already have a fully parsed MT940 file
+ def self.process(raw_bank_statement, account)
+ bank_statement_data = DataMapping::StatementFactory.new(raw_bank_statement, account).call
+ validate_params(bank_statement_data, account)
+ bank_statement = find_or_create_bank_statement(bank_statement_data, account)
+ update_meta_data(bank_statement_data, account)
+ bank_statement
+ end
+
+ def self.validate_params(raw_bank_statement, account)
+ raise(InvalidInput, "Cannot import empty bank statement.") if raw_bank_statement.blank?
+
+ validate_account!(raw_bank_statement, account)
+ end
+
+ # This is required as Deutsche Bank has a very weird MT940 file format
+ def self.validate_account!(raw_bank_statement, account)
+ account_number = raw_bank_statement.account_identification.account_number
+ return if account.iban.end_with?(account_number) || (account.iban + "00").end_with?(account_number)
+
+ raise(InvalidInput, "Cannot import bank statement for unknown sub-account #{account_number}.")
+ end
+
+ def self.find_or_create_bank_statement(raw_bank_statement, account)
+ BankStatement.find_or_create(sha: checksum(raw_bank_statement, account)) do |bs|
+ bs.account_id = account.id
+ bs.sequence = raw_bank_statement.sequence
+ bs.year = extract_year_from_bank_statement(raw_bank_statement)
+ bs.remote_account = raw_bank_statement.account_identification.source
+ bs.opening_balance = as_big_decimal(raw_bank_statement.opening_or_intermediary_balance) # this will be final or intermediate
+ bs.closing_balance = as_big_decimal(raw_bank_statement.closing_or_intermediary_balance) # this will be final or intermediate
+ bs.transaction_count = raw_bank_statement.transactions.count
+ bs.fetched_on = Date.today
+ bs.content = raw_bank_statement.source
+ end
+ end
+
+ private_class_method
+
+ def self.update_meta_data(raw_bank_statement, account)
+ balance = raw_bank_statement.closing_or_intermediary_balance # We have to handle both final and intermediary balances
+ return unless balance # vmk do not have a closing balance and thus cannot update it
+
+ if account.balance_date.blank? || account.balance_date <= balance.date
+ account.set_balance(balance.date, balance.amount_in_cents)
+ end
+ end
+
+ def self.as_big_decimal(input)
+ return if input.nil?
+
+ (input.amount * input.sign).to_d
+ end
+
+ def self.extract_year_from_bank_statement(raw_bank_statement)
+ first_transaction = raw_bank_statement.transactions.first
+ first_transaction&.date&.year
+ end
+
+ def self.checksum(raw_bank_statement, account)
+ payload = [
+ account.id,
+ extract_year_from_bank_statement(raw_bank_statement),
+ raw_bank_statement.source
+ ]
+
+ ChecksumGenerator.from_payload(payload)
+ end
+ end
+ end
+end
diff --git a/box/business_processes/import_statements.rb b/box/business_processes/import_statements.rb
new file mode 100644
index 00000000..f1b3cc01
--- /dev/null
+++ b/box/business_processes/import_statements.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require "cmxl"
+require "camt_parser"
+
+require_relative "../models/account"
+require_relative "../models/bank_statement"
+require_relative "../models/event"
+require_relative "../../lib/checksum_generator"
+
+module Box
+ module BusinessProcesses
+ class ImportStatements
+ PARSERS = {"mt940" => Cmxl, "camt53" => CamtParser::Format053::Statement}.freeze
+
+ def self.parse_bank_statement(bank_statement)
+ parser = PARSERS.fetch(bank_statement.account.statements_format, Cmxl)
+ result = parser.parse(bank_statement.content)
+ result.is_a?(Array) ? result.first.transactions : result.transactions
+ end
+
+ def self.from_bank_statement(bank_statement, upcoming = false)
+ bank_transactions = parse_bank_statement(bank_statement)
+
+ statements = bank_transactions.map do |bank_transaction|
+ create_statement(bank_statement, bank_transaction, upcoming)
+ end
+
+ stats = {total: bank_transactions.count, imported: statements.count(&:present?)}
+ Box.logger.info { "[BusinessProcesses::ImportStatements] Imported statements from bank statement. total=#{stats[:total]} imported=#{stats[:imported]}" }
+ stats
+ end
+
+ def self.create_statement(bank_statement, bank_transaction, upcoming = false)
+ account = bank_statement.account
+ trx = statement_attributes_from_bank_transaction(bank_transaction, bank_statement)
+
+ statement = account.statements_dataset.first(sha: trx[:sha])
+ if statement
+ Box.logger.debug("[BusinessProcesses::ImportStatements] Already imported. sha='#{statement.sha}'")
+ statement.update(settled: true) unless upcoming
+ false
+ else
+ statement = account.add_statement(trx.merge(bank_statement_id: bank_statement.id, settled: !upcoming))
+ Event.statement_created(statement)
+ link_statement_to_transaction(account, statement)
+ true
+ end
+ end
+
+ def self.link_statement_to_transaction(account, statement)
+ # find transactions via EREF
+ transaction = account.transactions_dataset.where(eref: statement.eref).first
+ # fallback to finding via statement information
+ transaction ||= account.transactions_dataset.exclude(currency: "EUR", status: %w[credit_received debit_received]).where { created_at > 14.days.ago }.detect { |t| statement.information =~ /#{t.eref}/i }
+
+ return unless transaction
+
+ transaction.add_statement(statement)
+
+ if statement.credit?
+ transaction.update_status("credit_received")
+ elsif statement.debit?
+ transaction.update_status("debit_received")
+ end
+ end
+
+ def self.checksum(transaction, bank_statement)
+ ChecksumGenerator.from_payload(checksum_attributes(transaction, bank_statement.remote_account))
+ end
+
+ def self.checksum_attributes(transaction, remote_account)
+ return [remote_account, transaction.transaction_id] if transaction.try(:transaction_id).present?
+
+ payload_from_transaction_attributes(transaction, remote_account)
+ end
+
+ def self.payload_from_transaction_attributes(transaction, remote_account)
+ eref = transaction.respond_to?(:eref) ? transaction.eref : transaction.sepa["EREF"]
+ mref = transaction.respond_to?(:mref) ? transaction.mref : transaction.sepa["MREF"]
+ svwz = transaction.respond_to?(:svwz) ? transaction.svwz : transaction.sepa["SVWZ"]
+
+ [
+ remote_account,
+ transaction.date,
+ transaction.amount_in_cents,
+ transaction.iban,
+ transaction.name,
+ transaction.sign,
+ eref,
+ mref,
+ svwz,
+ transaction.information.gsub(/\s/, "")
+ ]
+ end
+
+ def self.statement_attributes_from_bank_transaction(transaction, bank_statement)
+ {
+ sha: checksum(transaction, bank_statement),
+ date: transaction.date,
+ entry_date: transaction.entry_date,
+ amount: transaction.amount_in_cents,
+ sign: transaction.sign,
+ debit: transaction.debit?,
+ swift_code: transaction.swift_code,
+ reference: transaction.reference,
+ bank_reference: transaction.bank_reference,
+ bic: transaction.bic,
+ iban: transaction.iban,
+ name: transaction.name,
+ information: transaction.information,
+ description: transaction.description,
+ eref: transaction.respond_to?(:eref) ? transaction.eref : transaction.sepa["EREF"],
+ mref: transaction.respond_to?(:mref) ? transaction.mref : transaction.sepa["MREF"],
+ svwz: transaction.respond_to?(:svwz) ? transaction.svwz : transaction.sepa["SVWZ"],
+ tx_id: transaction.try(:primanota) || transaction.try(:transaction_id),
+ creditor_identifier: transaction.respond_to?(:creditor_identifier) ? transaction.creditor_identifier : transaction.sepa["CRED"]
+ }
+ end
+ end
+ end
+end
diff --git a/box/business_processes/new_account.rb b/box/business_processes/new_account.rb
new file mode 100644
index 00000000..48690e2b
--- /dev/null
+++ b/box/business_processes/new_account.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative "../models/event"
+
+module Box
+ module BusinessProcesses
+ class NewAccount
+ # Raised when something goes wrong when setting up remote ebics_user
+ EbicsError = Class.new(StandardError)
+
+ def self.create!(organization, user, params)
+ # Remove it, so we can safely pass params to account create method
+ ebics_user = params.delete(:ebics_user)
+
+ # Always create fake accounts in sandbox mode
+ params[:mode] = "Fake" if Box.configuration.sandbox?
+
+ DB.transaction do
+ account = organization.add_account(params)
+ ebics_user = EbicsUser.find_or_create(remote_user_id: ebics_user, user_id: user.id, partner: account.partner)
+ account.add_ebics_user(ebics_user) unless ebics_user.in?(account.ebics_users)
+
+ raise EbicsError unless ebics_user.setup!(account)
+
+ Event.account_created(account)
+ account
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/ebics_user.rb b/box/entities/ebics_user.rb
new file mode 100644
index 00000000..fcf8337a
--- /dev/null
+++ b/box/entities/ebics_user.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+module Box
+ module Entities
+ class EbicsUser < Grape::Entity
+ expose(:accounts, documentation: {type: "Array", desc: "Associated IBANs"}) do |ebics_user|
+ ebics_user.accounts.map(&:iban)
+ end
+ expose :id, documentation: {type: "Integer", desc: "Internal id"}
+ expose :user_id, documentation: {type: "String", desc: "Associated user id"}
+ expose :remote_user_id, as: "ebics_user", documentation: {type: "String", desc: "EBICS user identifier"}
+ expose :signature_class, documentation: {type: "String", desc: "EBICS signature class"}
+ expose :state, documentation: {type: "String", desc: "Current ebics_user state"}
+ expose :submitted_at, documentation: {type: "String", desc: "Date and time when EBICS keys were submitted to bank server"}
+ expose :activated_at, documentation: {type: "DateTime", desc: "Date and time when EBICS credentials have been activated"}
+
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |ebics_user, _options|
+ {
+ self: Box.configuration.app_url + "/management/accounts/#{ebics_user.first_account.iban}/ebics_users/#{ebics_user.id}",
+ ini_letter: Box.configuration.app_url + "/management/accounts/#{ebics_user.first_account.iban}/#{ebics_user.id}/ini_letter",
+ account: Box.configuration.app_url + "/management/accounts/#{ebics_user.first_account.iban}",
+ user: Box.configuration.app_url + "/management/users/#{ebics_user.user.try(:id)}"
+ }
+ end
+ end
+ end
+end
diff --git a/box/entities/management_account.rb b/box/entities/management_account.rb
new file mode 100644
index 00000000..3c04cc44
--- /dev/null
+++ b/box/entities/management_account.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+require_relative "ebics_user"
+
+module Box
+ module Entities
+ class ManagementAccount < Grape::Entity
+ expose :name, documentation: {type: "String", desc: "Display name for given bank account"}
+ expose :iban, documentation: {type: "String", desc: "Unique bank account IBAN"}
+ expose :bic, documentation: {type: "String", desc: "Bank branch's unique BIC"}
+ expose :bankname, documentation: {type: "String", desc: "Name of bank account's hosting bank"}
+ expose :creditor_identifier, documentation: {type: "String", desc: "Creditor identifier used for direct debits"}
+
+ expose :callback_url, documentation: {type: "String", desc: "URL where webhooks are sent at"}
+ expose :url, documentation: {type: "String", desc: "Bank's EBICS server URL"}
+ expose :host, documentation: {type: "String", desc: "EBICS Host identifier"}
+ expose :partner, documentation: {type: "String", desc: "EBICS partner identifier"}
+ expose :statements_format, documentation: {type: "String", desc: "Fetching method for statements (either 'mt940' or 'camt53')"}
+
+ expose(:test_mode, documentation: {type: "Boolean", desc: "Whether this is a test account"}) do |account|
+ account.mode == "File" || account.mode == "Fake"
+ end
+
+ expose :ebics_users, using: Entities::EbicsUser, if: {type: "full"}
+
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |account, _options|
+ {
+ self: Box.configuration.app_url + "/management/accounts/#{account.iban}",
+ ebics_users: Box.configuration.app_url + "/management/accounts/#{account.iban}/ebics_users"
+ }
+ end
+ end
+ end
+end
diff --git a/box/entities/statement.rb b/box/entities/statement.rb
new file mode 100644
index 00000000..dac62cbe
--- /dev/null
+++ b/box/entities/statement.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+require_relative "transaction"
+
+module Box
+ module Entities
+ class Statement < Grape::Entity
+ expose :public_id, as: "id"
+ expose(:account) { |statement| statement.account.iban }
+ expose :name
+ expose :bic
+ expose :iban
+ expose :type, documentation: {type: "Enum", desc: "Type of statement", values: %w[credit debit]}
+ expose :amount, documentation: {type: "Integer", desc: "Amount in cents"}
+ expose :date
+ expose(:remittance_information, documentation: {type: "String", desc: "Wire transfer reference"}) { |statement| statement[:svwz] || statement[:information] }
+ expose :eref, documentation: {type: "String", desc: "SEPA end-to-end reference"}
+ expose :mref, documentation: {type: "String", desc: "SEPA mandate reference"}
+ expose :reference, documentation: {type: "String", desc: "Additional references (like customer reference, etc.)"}
+ expose :bank_reference
+ expose :creditor_identifier, documentation: {type: "String", desc: "SEPA creditor identifier"}
+ expose :swift_code, as: :transaction_type, documentation: {type: "String", desc: "SWIFT transaction code"}
+ expose :tx_id, as: :transaction_id, documentation: {type: "String", desc: "Transaction ID as given by the bank"}
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |statement|
+ iban = statement.account.iban
+ trx = statement.transaction
+ {
+ self: Box.configuration.app_url + "/#{iban}/statements/#{statement.id}",
+ account: Box.configuration.app_url + "/#{iban}/",
+ transaction: (!!trx) ? Box.configuration.app_url + "/#{iban}/transactions/#{trx.id}" : nil
+ }
+ end
+ end
+ end
+end
diff --git a/box/entities/transaction.rb b/box/entities/transaction.rb
new file mode 100644
index 00000000..ab033e02
--- /dev/null
+++ b/box/entities/transaction.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+module Box
+ module Entities
+ class Transaction < Grape::Entity
+ expose(:account) { |statement| statement.account.iban }
+ expose(:eref)
+ expose(:type)
+ expose(:status)
+ expose(:order_type)
+ expose(:ebics_transaction_id)
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |trx|
+ iban = trx.account.iban
+ {
+ self: Box.configuration.app_url + "/#{iban}/transactions/#{trx.id}",
+ account: Box.configuration.app_url + "/#{iban}/",
+ statements: Box.configuration.app_url + "/#{iban}/statements?transaction_id=#{trx.id}"
+ }
+ end
+ end
+ end
+end
diff --git a/box/entities/user.rb b/box/entities/user.rb
new file mode 100644
index 00000000..dcf07ff0
--- /dev/null
+++ b/box/entities/user.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+require_relative "ebics_user"
+
+module Box
+ module Entities
+ class User < Grape::Entity
+ expose :id, documentation: {type: "Integer", desc: "Internal user id"}
+ expose :name, documentation: {type: "String", desc: "Display name for given bank account"}
+ expose :access_token, documentation: {type: "String", desc: "The user's access token"}, if: :include_token
+ expose :created_at, documentation: {type: "DateTime", desc: "Date and time when user was created"}
+ expose :admin, documentation: {type: "Boolean", desc: "Display admin state"}, if: :include_token
+ expose :ebics_users, using: Entities::EbicsUser, if: {type: "full"}
+
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |user, _options|
+ {
+ self: Box.configuration.app_url + "/management/users/#{user.id}"
+ }
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/account.rb b/box/entities/v2/account.rb
new file mode 100644
index 00000000..2c0cf19c
--- /dev/null
+++ b/box/entities/v2/account.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+require_relative "transaction"
+require_relative "../ebics_user"
+
+module Box
+ module Entities
+ module V2
+ class Account < Grape::Entity
+ expose :name, documentation: {type: "String", desc: "Name appearing on customer statements"}
+ expose :descriptor, documentation: {type: "String", desc: "Internal descriptor"}
+ expose :iban
+ expose :bic
+ expose :balance_date
+ expose :balance_in_cents
+ expose :creditor_identifier
+
+ expose :ebics_users, using: Entities::EbicsUser
+ expose(:url)
+ expose(:partner)
+ expose(:host)
+ expose(:status)
+
+ expose(:callback_url)
+
+ expose(:_links) do |account, _options|
+ {
+ self: Box.configuration.app_url + "/accounts/#{account.iban}",
+ transactions: Box.configuration.app_url + "/transactions?iban=#{account.iban}",
+ ini_letter: Box.configuration.app_url + "/accounts/#{account.iban}/ini_letter"
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/credit_transfer.rb b/box/entities/v2/credit_transfer.rb
new file mode 100644
index 00000000..de7f8987
--- /dev/null
+++ b/box/entities/v2/credit_transfer.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+require_relative "transaction"
+
+module Box
+ module Entities
+ module V2
+ class CreditTransfer < Grape::Entity
+ expose(:public_id, as: "id")
+ expose(:account) { |transaction| transaction.account.iban }
+ expose(:name)
+ expose(:iban)
+ expose(:bic)
+ expose(:amount, as: "amount_in_cents")
+ expose(:currency)
+ expose(:eref, as: "end_to_end_reference")
+ expose(:ebics_order_id)
+ expose(:ebics_transaction_id)
+ expose(:reference)
+ expose(:executed_on)
+ expose(:status)
+ expose(:_links) do |transaction|
+ iban = transaction.account.iban
+ {
+ self: Box.configuration.app_url + "/credit_transfers/#{transaction.public_id}",
+ account: Box.configuration.app_url + "/accounts/#{iban}/"
+ }
+ end
+
+ def name
+ object.metadata.fetch("name") do
+ object.parsed_payload[:payments].first[:transactions].first[:name]
+ end
+ end
+
+ def iban
+ object.metadata.fetch("iban") do
+ object.parsed_payload[:payments].first[:transactions].first[:iban]
+ end
+ end
+
+ def bic
+ object.metadata.fetch("bic") do
+ object.parsed_payload[:payments].first[:transactions].first[:bic]
+ end
+ end
+
+ def reference
+ object.metadata.fetch("reference") do
+ object.parsed_payload[:payments].first[:transactions].first[:remittance_information]
+ end
+ end
+
+ def executed_on
+ object.metadata.fetch("execution_date") do
+ object.parsed_payload[:payments].first[:execution_date]
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/direct_debit.rb b/box/entities/v2/direct_debit.rb
new file mode 100644
index 00000000..2df53049
--- /dev/null
+++ b/box/entities/v2/direct_debit.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+require "ostruct"
+require_relative "transaction"
+
+module Box
+ module Entities
+ module V2
+ class DirectDebit < Grape::Entity
+ expose(:public_id, as: "id")
+ expose(:account) { |transaction| transaction.account.iban }
+ expose(:name)
+ expose(:iban)
+ expose(:bic)
+ expose(:amount, as: "amount_in_cents")
+ expose(:eref, as: "end_to_end_reference")
+ expose(:ebics_order_id)
+ expose(:ebics_transaction_id)
+ expose(:reference)
+ expose(:collection_date)
+ expose(:status)
+ expose(:_links) do |transaction|
+ iban = transaction.account.iban
+ {
+ self: Box.configuration.app_url + "/direct_debits/#{transaction.id}",
+ account: Box.configuration.app_url + "/accounts/#{iban}/"
+ }
+ end
+
+ def name
+ first_transaction.name
+ end
+
+ def iban
+ first_transaction.iban
+ end
+
+ def bic
+ first_transaction.bic
+ end
+
+ def reference
+ first_transaction.remittance_information
+ end
+
+ def collection_date
+ payments.first[:collection_date]
+ end
+
+ private
+
+ def first_transaction
+ OpenStruct.new(payments.first[:transactions]&.first)
+ end
+
+ def payments
+ object.parsed_payload.fetch(:payments, [])
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/event.rb b/box/entities/v2/event.rb
new file mode 100644
index 00000000..0a06515c
--- /dev/null
+++ b/box/entities/v2/event.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+require_relative "../webhook_delivery"
+
+module Box
+ module Entities
+ module V2
+ MAPPED_TYPES = {
+ "statement_created" => "transaction_created",
+ "ebics_user_activated" => "account_activated"
+ }.freeze
+ class Event < Grape::Entity
+ expose :public_id, as: "id"
+ expose(:account, documentation: {type: "String", desc: "Display name for given bank account"}) do |event|
+ event.account.try(:iban)
+ end
+ expose(:type) { |event| MAPPED_TYPES[event.type] || event.type }
+ expose :payload
+ expose :triggered_at
+ expose :signature
+ expose :webhook_status
+ expose :webhook_retries
+
+ expose :webhook_deliveries, using: Entities::WebhookDelivery, if: {type: "full"}
+
+ expose(:_links, documentation: {type: "Hash", desc: "Links to resources"}) do |event, _options|
+ {
+ self: Box.configuration.app_url + "/events/#{event.public_id}",
+ account: Box.configuration.app_url + "/accounts/#{event.account.try(:iban)}"
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/message.rb b/box/entities/v2/message.rb
new file mode 100644
index 00000000..4a81fa47
--- /dev/null
+++ b/box/entities/v2/message.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+require_relative "transaction"
+
+module Box
+ module Entities
+ module V2
+ class Message < Grape::Entity
+ expose(:message)
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/organization.rb b/box/entities/v2/organization.rb
new file mode 100644
index 00000000..5de03eb7
--- /dev/null
+++ b/box/entities/v2/organization.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+require_relative "../ebics_user"
+require_relative "../user"
+
+module Box
+ module Entities
+ module V2
+ class Organization < Grape::Entity
+ expose :name, documentation: {type: "String", desc: "Display name for given organization"}
+ expose :webhook_token, documentation: {type: "String", desc: "Token for validation of webhook signature"}
+ expose :users, documentation: {type: "String", desc: "Administrative user which is created along"}, using: Entities::User
+ end
+ end
+ end
+end
diff --git a/box/entities/v2/transaction.rb b/box/entities/v2/transaction.rb
new file mode 100644
index 00000000..e6d3737b
--- /dev/null
+++ b/box/entities/v2/transaction.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+module Box
+ module Entities
+ module V2
+ class Transaction < Grape::Entity
+ expose :public_id, as: "id"
+ expose(:account) { |transaction| transaction.account.iban }
+ expose :name
+ expose :iban
+ expose :bic
+ expose :type
+ expose :amount, as: "amount_in_cents"
+ expose :date, as: "executed_on"
+ expose(:settled_at) { |trx| trx.settled ? trx.date : nil }
+ expose(:reference) { |transaction| transaction[:svwz] || transaction[:information] }
+ expose :eref, as: "end_to_end_reference"
+ expose(:_links) do |transaction|
+ iban = transaction.account.iban
+ {
+ self: Box.configuration.app_url + "/transactions/#{transaction.public_id}",
+ account: Box.configuration.app_url + "/accounts/#{iban}/"
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/box/entities/webhook_delivery.rb b/box/entities/webhook_delivery.rb
new file mode 100644
index 00000000..31ce8ac1
--- /dev/null
+++ b/box/entities/webhook_delivery.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "grape-entity"
+
+module Box
+ module Entities
+ class WebhookDelivery < Grape::Entity
+ expose :delivered_at
+ expose :response_body
+ expose :reponse_headers
+ expose :response_status
+ expose :response_time
+ end
+ end
+end
diff --git a/box/errors/business_process_failure.rb b/box/errors/business_process_failure.rb
new file mode 100644
index 00000000..ac4e9403
--- /dev/null
+++ b/box/errors/business_process_failure.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Box
+ class BusinessProcessFailure < RuntimeError
+ attr_accessor :errors
+
+ def initialize(errors, msg = nil)
+ super(msg || errors.full_messages.join(" "))
+ self.errors = errors
+ end
+ end
+end
diff --git a/box/helpers/default.rb b/box/helpers/default.rb
new file mode 100644
index 00000000..468b4176
--- /dev/null
+++ b/box/helpers/default.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "jwt"
+
+require_relative "../models/organization"
+require_relative "../models/user"
+
+module Box
+ module Helpers
+ module Default
+ def current_user
+ env["box.user"]
+ end
+
+ def current_organization
+ env["box.organization"]
+ end
+
+ def account
+ current_organization.find_account!(params[:account])
+ end
+
+ def logger
+ Box.logger
+ end
+ end
+ end
+end
diff --git a/box/helpers/error_handler.rb b/box/helpers/error_handler.rb
new file mode 100644
index 00000000..bcf024cc
--- /dev/null
+++ b/box/helpers/error_handler.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Box
+ module Helpers
+ module ErrorHandler
+ def log_error(exception, logger_prefix: "generic")
+ Sentry.capture_exception(exception) if ENV["SENTRY_DSN"]
+ Rollbar.error(exception) if ENV["ROLLBAR_ACCESS_TOKEN"]
+ Box.logger.error("[#{logger_prefix}] #{exception.class} :: \"#{exception.message}\"")
+ end
+ end
+ end
+end
diff --git a/box/helpers/pagination.rb b/box/helpers/pagination.rb
new file mode 100644
index 00000000..fee1c3bc
--- /dev/null
+++ b/box/helpers/pagination.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Box
+ module Helpers
+ module Pagination
+ def setup_pagination_header(record_count)
+ # Calculate total pages
+ total_pages, remainder = record_count.divmod(params["per_page"])
+ total_pages += 1 if remainder.positive?
+
+ # Build urls
+ urls = {
+ next: build_path("page" => params["page"] + 1),
+ prev: build_path("page" => params["page"] - 1),
+ first: build_path("page" => 1),
+ last: build_path("page" => total_pages)
+ }
+
+ # Remove urls which do not make any sense to display
+ if params["page"] >= total_pages
+ urls.delete(:next) if params["page"]
+ urls.delete(:last) if params["page"]
+ end
+
+ if params["page"] == 1
+ urls.delete(:prev)
+ urls.delete(:first)
+ end
+
+ # Set pagination header
+ header "Link", urls.map { |rel, url| "<#{url}>; rel='#{rel}'" }.join(",")
+ end
+
+ def build_path(new_params)
+ new_query = Rack::Utils.build_query(request.env["rack.request.query_hash"].merge(new_params))
+ Rack::Request.new(request.env).url.split("?").first + "?" + new_query
+ end
+ end
+ end
+end
diff --git a/box/jobs/check_activation.rb b/box/jobs/check_activation.rb
new file mode 100644
index 00000000..8a22cbc3
--- /dev/null
+++ b/box/jobs/check_activation.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require_relative "../queue"
+require_relative "../models/ebics_user"
+
+module Box
+ module Jobs
+ class CheckActivation
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.activations", retry: false
+
+ def perform
+ ebics_users = EbicsUser.where(activated_at: nil).exclude(ini_letter: nil)
+ ebics_users.each do |user|
+ activate_ebics_user(user)
+ end
+ end
+
+ private
+
+ def activate_ebics_user(ebics_user)
+ if ebics_user.activate!
+ Box.logger.info("[Jobs::CheckActivation] Activated ebics_user! ebics_user_id=#{ebics_user.id}")
+ else
+ Box.logger.info("[Jobs::CheckActivation] Failed to activate ebics_user! ebics_user_id=#{ebics_user.id}")
+ end
+ end
+ end
+ end
+end
diff --git a/box/jobs/credit.rb b/box/jobs/credit.rb
new file mode 100644
index 00000000..22d38da6
--- /dev/null
+++ b/box/jobs/credit.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "base64"
+
+require_relative "../queue"
+require_relative "../models/event"
+require_relative "../models/transaction"
+
+module Box
+ module Jobs
+ class Credit
+ include Sidekiq::Worker
+ sidekiq_options queue: "credit", retry: 5
+
+ INSTRUMENT_MAPPING = Hash.new("AZV").update(
+ "EUR" => :CCT
+ )
+
+ def perform(message)
+ message.symbolize_keys!
+ transaction = Transaction.find_or_create(user_id: message[:user_id], eref: message[:eref]) do |trx|
+ trx.account_id = message[:account_id]
+ trx.amount = message[:amount]
+ trx.type = "credit"
+ trx.payload = Base64.strict_decode64(message[:payload])
+ trx.currency = message[:currency]
+ trx.status = "created"
+ trx.order_type = INSTRUMENT_MAPPING[message[:currency]]
+ trx.metadata = message[:metadata]
+ end
+
+ return false unless transaction.status == "created"
+
+ transaction.execute!
+
+ Event.credit_created(transaction)
+ Queue.update_processing_status(message[:account_id])
+
+ Box.logger.info("[Jobs::Credit] Created credit! transaction_id=#{transaction.id}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/debit.rb b/box/jobs/debit.rb
new file mode 100644
index 00000000..2ae573db
--- /dev/null
+++ b/box/jobs/debit.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "base64"
+
+require_relative "../queue"
+require_relative "../models/event"
+require_relative "../models/transaction"
+
+module Box
+ module Jobs
+ class Debit
+ include Sidekiq::Worker
+ sidekiq_options queue: "debit"
+
+ INSTRUMENT_MAPPING = {
+ "CORE" => :CDD,
+ "COR1" => :CD1,
+ "B2B" => :CDB
+ }.freeze
+
+ def perform(message)
+ message.symbolize_keys!
+ transaction = Box::Transaction.find_or_create(user_id: message[:user_id], eref: message[:eref]) do |trx|
+ trx.amount = message[:amount]
+ trx.type = "debit"
+ trx.order_type = INSTRUMENT_MAPPING[message[:instrument]]
+ trx.account_id = message[:account_id]
+ trx.payload = Base64.strict_decode64(message[:payload])
+ trx.status = "created"
+ end
+
+ return false unless transaction.status == "created"
+
+ transaction.execute!
+
+ Event.debit_created(transaction)
+ Queue.update_processing_status(message[:account_id])
+
+ Box.logger.info("[Jobs::Debit] Created debit! transaction_id=#{transaction.id}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/fetch_processing_status.rb b/box/jobs/fetch_processing_status.rb
new file mode 100644
index 00000000..971e9d2a
--- /dev/null
+++ b/box/jobs/fetch_processing_status.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+require_relative "../models/account"
+require_relative "../models/transaction"
+
+module Box
+ module Jobs
+ class FetchProcessingStatus
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.orders", retry: true
+
+ def perform(account_id)
+ log(:debug, "Reconciling orders by HAC.", account_id: account_id)
+ remote_records(account_id).each do |data|
+ update_transaction(account_id, data)
+ end
+ end
+
+ def update_transaction(account_id, info)
+ order_id = info[:ids]["OrderID"]
+ return unless order_id
+
+ trx = Transaction.last(ebics_order_id: order_id, account_id: account_id)
+
+ unless trx
+ log(:info, "Transaction not found.", account_id: account_id, order_id: order_id, info: info)
+ return
+ end
+
+ trx.update_status(info[:action], reason: info[:reason_code])
+ log(:info, "Transaction status change.",
+ account_id: account_id,
+ ebics_order_id: order_id,
+ transaction_id: trx.public_id,
+ status: info[:action])
+ end
+
+ def remote_records(account_id)
+ account = Account[account_id]
+ file = account.transport_client.HAC
+ Nokogiri::XML(file).remove_namespaces!.xpath("//OrgnlPmtInfAndSts").map do |info|
+ {
+ reason_code: info.xpath("./StsRsnInf/Rsn/Cd").text,
+ action: info.xpath("./OrgnlPmtInfId").text.downcase,
+ ids: info.xpath("./StsRsnInf/Orgtr/Id/OrgId/Othr").each_with_object({}) do |node, memo|
+ memo[node.at_xpath("./SchmeNm/Prtry").text] = node.at_xpath("./Id").text
+ end
+ }
+ end
+ end
+
+ def log(type, message, data = {})
+ data = data.map { |k, v| "#{k}=#{v}" }.join(" ")
+ Box.logger.public_send(type, "[Jobs::FetchProcessingStatus] #{message} #{data}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/fetch_statements.rb b/box/jobs/fetch_statements.rb
new file mode 100644
index 00000000..a5991625
--- /dev/null
+++ b/box/jobs/fetch_statements.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require "sidekiq-scheduler"
+require "active_support/all"
+require "camt_parser"
+require "cmxl"
+require "epics"
+require "sequel"
+
+require_relative "../business_processes/import_bank_statement"
+require_relative "../business_processes/import_statements"
+require_relative "../models/account"
+
+module Box
+ module Jobs
+ class FetchStatementsError < StandardError; end
+
+ class FetchStatements
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.statements", retry: false
+
+ attr_accessor :from, :to
+
+ def perform(account_id, options = {})
+ account = Account[account_id]
+ raise FetchStatementsError, "No Account found for #{account_id}" unless account
+
+ options.symbolize_keys!
+
+ self.from = options.fetch(:from, 7.days.ago.to_date)
+ self.to = options.fetch(:to, Date.today)
+
+ fetch_for_account(account)
+ end
+
+ # Fetch all new statements for a single account since its last import. Each account import
+ # can fail and should not affect imports for other accounts.
+ def fetch_for_account(account)
+ method = account.statements_format
+
+ chunks = send(method, account.transport_client, from, to)
+ return unless chunks
+
+ # Store all fetched bank statements for later usage
+ import_stats = import_to_database(chunks, account)
+
+ # Update imported at timestamp
+ update_account_last_import(account, to)
+
+ Box.logger.info { "[Jobs::FetchStatements] Imported bank statements. id=#{account.id} bank_statement_count=#{chunks.count}" }
+
+ import_stats
+ rescue Sequel::NoMatchingRow => ex
+ Box.logger.error { "[Jobs::FetchStatements] Could not find account. account.id=#{account.id}" }
+ rescue Epics::Error::BusinessError => ex
+ # The BusinessError can occur when no new statements are available
+ Box.logger.error { "[Jobs::FetchStatements] EBICS error. id=#{account.id} reason='#{ex.message}'" }
+ end
+
+ def import_to_database(chunks, account)
+ chunks.map do |chunk|
+ bank_statement = BusinessProcesses::ImportBankStatement.process(chunk, account)
+ BusinessProcesses::ImportStatements.from_bank_statement(bank_statement)
+ rescue BusinessProcesses::ImportBankStatement::InvalidInput => ex
+ Box.logger.error { "[Jobs::FetchStatements] #{ex} account.id=#{account.id}" }
+ {total: 0, imported: 0}
+ end.reduce(total: 0, imported: 0) do |memo, chunk_stats|
+ {
+ total: memo[:total] + chunk_stats[:total],
+ imported: memo[:imported] + chunk_stats[:imported]
+ }
+ end
+ end
+
+ # TODO: Refactor this shitty implementation
+ def update_account_last_import(account, to)
+ imported_at = account.last_imported_at
+ account.imported_at!(Time.now) if !imported_at || imported_at <= to
+ end
+
+ private
+
+ def camt53(client, from, to)
+ combined_camt = client.C53(from.to_s(:db), to.to_s(:db))
+ return unless combined_camt.any?
+
+ combined_camt.map { |chunk| CamtParser::String.parse(chunk).statements }.flatten
+ end
+
+ def mt940(client, from, to)
+ combined_mt940 = client.STA(from.to_s(:db), to.to_s(:db))
+ return unless combined_mt940
+
+ Cmxl.parse(combined_mt940)
+ end
+ end
+ end
+end
diff --git a/box/jobs/fetch_upcoming_statements.rb b/box/jobs/fetch_upcoming_statements.rb
new file mode 100644
index 00000000..84408782
--- /dev/null
+++ b/box/jobs/fetch_upcoming_statements.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "sidekiq-scheduler"
+require "active_support/all"
+require "camt_parser"
+require "cmxl"
+require "epics"
+require "sequel"
+
+require_relative "../business_processes/import_bank_statement"
+require_relative "../business_processes/import_statements"
+require_relative "../models/account"
+
+module Box
+ module Jobs
+ class FetchUpcomingStatementsError < StandardError; end
+
+ class FetchUpcomingStatements
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.statements", retry: false
+
+ attr_accessor :options
+
+ def perform(account_id, options = {})
+ account = Account[account_id]
+ raise FetchUpcomingStatementsError, "Account-ID missing" unless account
+
+ self.options = options.symbolize_keys!
+
+ fetch_for_account(account)
+ end
+
+ def fetch_for_account(account)
+ if account.statements_format != "mt940"
+ Box.logger.info("[Jobs::FetchUpcomingStatements] Skip VMK for #{account.id}. Currently only MT942 is supported")
+ return
+ end
+
+ vmk_data = account.transport_client.VMK(safe_from.to_s(:db), safe_to.to_s(:db))
+ return unless vmk_data
+
+ chunks = Cmxl.parse(vmk_data)
+ import_stats = import_to_database(chunks, account)
+
+ Box.logger.info("[Jobs::FetchUpcomingStatements] Imported #{chunks.count} VMK(s) for Account ##{account.id}.")
+
+ import_stats
+ rescue Sequel::NoMatchingRow => _ex
+ Box.logger.error("[Jobs::FetchUpcomingStatements] Could not find Account ##{account.id}")
+ rescue Epics::Error::BusinessError => ex
+ if ENV["SENTRY_DSN"]
+ Sentry.add_attachment(filename: "#{account.id}_vmk_mt942_#{SecureRandom.uuid}", bytes: vmk_data) if !vmk_data.empty?
+ Sentry.capture_exception(ex)
+ end
+ # The BusinessError can occur when no new statements are available
+ Box.logger.error("[Jobs::FetchUpcomingStatements] EBICS error. id=#{account.id} reason='#{ex.message}'")
+ end
+
+ def import_to_database(chunks, account)
+ chunks.reduce(total: 0, imported: 0) do |memo, chunk|
+ bank_statement = BusinessProcesses::ImportBankStatement.process(chunk, account)
+ result = BusinessProcesses::ImportStatements.from_bank_statement(bank_statement, upcoming: true)
+
+ {total: memo[:total] + result[:total], imported: memo[:imported] + result[:imported]}
+ rescue BusinessProcesses::ImportBankStatement::InvalidInput => e
+ Box.logger.error("[Jobs::FetchUpcomingStatements] #{e} account_id=#{account.id}")
+ memo
+ end
+ end
+
+ private
+
+ def safe_from
+ options&.dig(:from) || Date.today
+ end
+
+ def safe_to
+ options&.dig(:to) || 30.days.from_now.to_date
+ end
+ end
+ end
+end
diff --git a/box/jobs/queue_fetch_statements.rb b/box/jobs/queue_fetch_statements.rb
new file mode 100644
index 00000000..d50d4ea9
--- /dev/null
+++ b/box/jobs/queue_fetch_statements.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "sidekiq-scheduler"
+require "active_support/all"
+require "camt_parser"
+require "cmxl"
+require "epics"
+require "sequel"
+
+require_relative "../business_processes/import_bank_statement"
+require_relative "../business_processes/import_statements"
+require_relative "../models/account"
+
+module Box
+ module Jobs
+ class QueueFetchStatements
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.statements", retry: false
+
+ def perform(account_ids = [], options = {})
+ log(:debug, "Queue fetch statements")
+ account_ids = Account.all_active_ids if account_ids.empty?
+
+ account_ids.each do |account_id|
+ FetchStatements.perform_async(account_id, options)
+ end
+ end
+
+ private
+
+ def log(type, message, data = {})
+ data = data.map { |k, v| "#{k}=#{v}" }.join(" ")
+ Box.logger.public_send(type, "[#{self.class.name}] #{message} #{data}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/queue_fetch_upcoming_statements.rb b/box/jobs/queue_fetch_upcoming_statements.rb
new file mode 100644
index 00000000..bdfa8299
--- /dev/null
+++ b/box/jobs/queue_fetch_upcoming_statements.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "sidekiq-scheduler"
+require "active_support/all"
+require "camt_parser"
+require "cmxl"
+require "epics"
+require "sequel"
+
+require_relative "../business_processes/import_bank_statement"
+require_relative "../business_processes/import_statements"
+require_relative "../models/account"
+
+module Box
+ module Jobs
+ class QueueFetchUpcomingStatements
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.statements", retry: false
+
+ def perform(account_ids = [], options = {})
+ log(:debug, "Queue fetch upcoming statements")
+ account_ids = Account.all_active_ids if account_ids.empty?
+
+ account_ids.each do |account_id|
+ FetchUpcomingStatements.perform_async(account_id, options)
+ end
+ end
+
+ private
+
+ def log(type, message, data = {})
+ data = data.map { |k, v| "#{k}=#{v}" }.join(" ")
+ Box.logger.public_send(type, "[#{self.class.name}] #{message} #{data}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/queue_processing_status.rb b/box/jobs/queue_processing_status.rb
new file mode 100644
index 00000000..5f329206
--- /dev/null
+++ b/box/jobs/queue_processing_status.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "sidekiq-scheduler"
+require "nokogiri"
+
+module Box
+ module Jobs
+ class QueueProcessingStatus
+ include Sidekiq::Worker
+ sidekiq_options queue: "check.orders", retry: false
+
+ def perform(account_ids = [])
+ log(:debug, "Check orders.")
+ account_ids = Account.all_active_ids if account_ids.empty?
+
+ account_ids.each do |account_id|
+ FetchProcessingStatus.perform_async(account_id)
+ end
+ end
+
+ private
+
+ def log(type, message, data = {})
+ data = data.map { |k, v| "#{k}=#{v}" }.join(" ")
+ Box.logger.public_send(type, "[#{self.class.name}] #{message} #{data}")
+ end
+ end
+ end
+end
diff --git a/box/jobs/webhook.rb b/box/jobs/webhook.rb
new file mode 100644
index 00000000..dc34e85b
--- /dev/null
+++ b/box/jobs/webhook.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require_relative "../models/event"
+require_relative "../models/webhook_delivery"
+
+module Box
+ module Jobs
+ class Webhook
+ include Sidekiq::Worker
+ sidekiq_options queue: "webhooks"
+
+ def perform(event_id)
+ event = Event.find(id: event_id)
+ if event
+ delivery = WebhookDelivery.deliver(event)
+ Box.logger.info("[Jobs::Webhook] Attempt to deliver a webhook. event_id=#{event.id} delivery_id=#{delivery.id}")
+ else
+ Box.logger.error("[Jobs::Webhook] Failed to deliver a webhook. No event found. event_id=#{event_id}")
+ end
+ end
+ end
+ end
+end
diff --git a/box/middleware/connection_validator.rb b/box/middleware/connection_validator.rb
new file mode 100644
index 00000000..d41c431e
--- /dev/null
+++ b/box/middleware/connection_validator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Box
+ module Middleware
+ class ConnectionValidator
+ def initialize(app, db)
+ @app = app
+ @db = db
+
+ @db.extension(:connection_validator)
+ @db.pool.connection_validation_timeout = -1
+ end
+
+ def call(env)
+ @db.synchronize do
+ @app.call(env)
+ end
+ end
+ end
+ end
+end
diff --git a/box/middleware/oauth_authentication.rb b/box/middleware/oauth_authentication.rb
new file mode 100644
index 00000000..e96c1b02
--- /dev/null
+++ b/box/middleware/oauth_authentication.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require "faraday"
+require "rack"
+require "jwt"
+require "uri"
+
+require_relative "../models/user"
+require_relative "../models/organization"
+
+module Box
+ module Middleware
+ class OauthAuthentication
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ request = Rack::Request.new(env)
+ auth_data = load_user_auth_data(request)
+ @app.call(env.merge(auth_data))
+ end
+
+ private
+
+ def load_user_auth_data(request)
+ access_token = request.params["access_token"] || request.env["HTTP_AUTHORIZATION"].to_s[/\A(?:token|Bearer) (.+)\z/, 1]
+ data = JWT.decode(access_token, Box.configuration.jwt_secret, true, algorithm: "HS512", verify_jti: ->(_) { validate_token(access_token) }).first
+ orga_data = data["organization"]
+
+ organization = Organization.find_or_create(id: orga_data["sub"], name: orga_data["name"]) do |orga|
+ orga.webhook_token = SecureRandom.hex
+ end
+ user = User.find_or_create(id: data["sub"], name: data["name"], organization_id: organization.id)
+
+ {
+ "box.user" => user,
+ "box.organization" => organization,
+ "box.admin" => data["ebicsbox"]["role"].include?("admin")
+ }
+ rescue JWT::DecodeError => ex
+ Box.logger.info { "[OauthAuthentication] #{ex.message}" }
+ {
+ "box.user" => nil,
+ "box.organization" => nil,
+ "box.admin" => false
+ }
+ end
+
+ def validate_token(access_token)
+ request = Faraday.new URI(Box.configuration.oauth_server) do |conn|
+ conn.request :authorization, "Bearer", access_token
+ end
+
+ request.head("oauth/token/info").success?
+ end
+ end
+ end
+end
diff --git a/box/middleware/signer.rb b/box/middleware/signer.rb
new file mode 100644
index 00000000..389d8a5c
--- /dev/null
+++ b/box/middleware/signer.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "faraday"
+
+module Box
+ module Middleware
+ class Signer < Faraday::Middleware
+ SIGNATURE_HEADER = "X-Signature"
+
+ def initialize(app, opts = {})
+ super(app)
+ @opts = opts
+ end
+
+ def call(env)
+ env.request_headers[SIGNATURE_HEADER] ||= sign(env.request_body).to_s if secret
+ @app.call(env)
+ end
+
+ private
+
+ def sign(msg)
+ "sha1=" + OpenSSL::HMAC.hexdigest(digest, secret, msg.to_s)
+ end
+
+ def digest
+ OpenSSL::Digest.new("sha1")
+ end
+
+ def secret
+ @opts[:secret]
+ end
+ end
+ end
+end
+
+Faraday::Request.register_middleware signer: -> { Box::Middleware::Signer }
diff --git a/box/middleware/static_authentication.rb b/box/middleware/static_authentication.rb
new file mode 100644
index 00000000..2b06fedb
--- /dev/null
+++ b/box/middleware/static_authentication.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "rack"
+require_relative "../models/user"
+require_relative "../models/organization"
+
+module Box
+ module Middleware
+ class StaticAuthentication
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ request = Rack::Request.new(env)
+ auth_data = load_user_auth_data(request)
+ @app.call(env.merge(auth_data))
+ end
+
+ private
+
+ def load_user_auth_data(request)
+ access_token = request.env["HTTP_AUTHORIZATION"].to_s[/\A(?:token|Bearer) (.+)\z/, 1]
+ user = User.find_by_access_token(access_token)
+
+ {
+ "box.user" => user,
+ "box.organization" => user.try(:organization),
+ "box.admin" => user.try(:admin)
+ }
+ end
+ end
+ end
+end
diff --git a/box/models/account.rb b/box/models/account.rb
new file mode 100644
index 00000000..d5a3bfd3
--- /dev/null
+++ b/box/models/account.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require "sequel"
+require "securerandom"
+require "ostruct"
+
+module Box
+ class Account < Sequel::Model
+ class Config
+ attr_accessor :config
+
+ def initialize(config_hash)
+ self.config = OpenStruct.new(config_hash)
+ end
+ end
+
+ self.raise_on_save_failure = true
+
+ NoTransportClient = Class.new(StandardError)
+ NotActivated = Class.new(StandardError)
+ NotFound = Class.new(ArgumentError) do
+ attr_accessor :organization_id, :iban
+
+ def self.for_orga(organization_id:, iban:)
+ new("Could not find account! iban=#{iban} organization_id=#{organization_id}").tap do |error|
+ error.organization_id = organization_id
+ error.iban = iban
+ end
+ end
+ end
+
+ one_to_many :bank_statements
+ one_to_many :events
+ one_to_many :statements
+ many_to_many :ebics_users
+ one_to_many :transactions
+ many_to_one :organization
+
+ dataset_module do
+ def by_organization(organization)
+ where(organization_id: organization.id)
+ end
+
+ def filtered(params)
+ query = self
+ # Filter by status
+ case params[:status]
+ when "activated" then query.eager_graph(:ebics_users).exclude(ebics_users__activated_at: nil)
+ when "not_activated" then query.eager_graph(:ebics_users).where(ebics_users__activated_at: nil)
+ else query
+ end
+ end
+
+ def paginate(params)
+ limit(params[:per_page])
+ .offset((params[:page] - 1) * params[:per_page])
+ .order(:name)
+ end
+ end
+
+ def config
+ Config.new(super)
+ end
+
+ def client_adapter
+ Box::Adapters.const_get(mode)
+ rescue => _ex
+ Box.configuration.ebics_client
+ end
+
+ def transport_client
+ @transport_client ||= begin
+ base_scope = ebics_users_dataset.exclude(ebics_users__activated_at: nil)
+ ebics_user = base_scope.where(ebics_users__signature_class: "T").first || base_scope.first
+ if ebics_user.nil?
+ raise NoTransportClient, "Please setup and activate at least one ebics_user with a transport signature"
+ else
+ ebics_user.client
+ end
+ end
+ end
+
+ def client_for(user_id)
+ ebics_user_for(user_id).client
+ end
+
+ def ebics_user_for(user_id)
+ ebics_users_dataset.first(user_id: user_id)
+ end
+
+ def self.all_active_ids
+ association_join(:ebics_users).select(:accounts__id).exclude(ebics_users__activated_at: nil).map(&:id)
+ end
+
+ def active?
+ ebics_users.any?(&:active?)
+ end
+
+ def status
+ active? ? "activated" : "not_activated"
+ end
+
+ def pain_attributes_hash
+ raise(NotActivated) unless active?
+
+ values.slice(:name, :bic, :iban, :creditor_identifier)
+ end
+
+ def credit_pain_attributes_hash
+ raise(NotActivated) unless active?
+
+ values.slice(:name, :bic, :iban)
+ end
+
+ def bank_account_number
+ @bank_account_number ||= begin
+ bank_account_metadata
+ @bank_account_number
+ end
+ end
+
+ def bank_number
+ @bank_number ||= begin
+ bank_account_metadata
+ @bank_number
+ end
+ end
+
+ def bank_country_code
+ iban[0...2]
+ end
+
+ def bank_account_metadata
+ Nokogiri::XML(transport_client.HTD).tap do |htd|
+ @bank_account_number ||= htd.at_xpath("//xmlns:AccountNumber[@international='false']", xmlns: "urn:org:ebics:H004").text
+ @bank_number ||= htd.at_xpath("//xmlns:BankCode[@international='false']", xmlns: "urn:org:ebics:H004").text
+ end
+ end
+
+ def last_imported_at
+ DB[:imports].where(account_id: id).order(:date).last.try(:[], :date)
+ end
+
+ def imported_at!(date)
+ DB[:imports].insert(date: date, account_id: id)
+ end
+
+ def set_balance(date, amount_in_cents)
+ self.balance_date = date
+ self.balance_in_cents = amount_in_cents
+ save
+ end
+
+ def setup_ebics_user!(user_id, ebics_user)
+ DB.transaction do
+ raise "This user already has a ebics_user for this account." if ebics_user_for(user_id)
+
+ if ebics_users_dataset.where(remote_user_id: ebics_user).any?
+ raise "Another user is using the same EBICS user id."
+ end
+
+ ebics_user = EbicsUser.find_or_create(user_id: user_id, remote_user_id: ebics_user)
+ raise "Failed to create ebics_user." unless ebics_user
+
+ add_ebics_user(ebics_user)
+ ebics_user.setup!(account)
+ ebics_user
+ end
+ end
+
+ def as_event_payload
+ {
+ account_id: id,
+ account: to_hash
+ }
+ end
+ end
+end
diff --git a/box/models/bank_statement.rb b/box/models/bank_statement.rb
new file mode 100644
index 00000000..13f94d00
--- /dev/null
+++ b/box/models/bank_statement.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "securerandom"
+require "sequel"
+
+module Box
+ class BankStatement < Sequel::Model
+ self.raise_on_save_failure = true
+
+ many_to_one :account
+ one_to_many :statements
+ end
+end
diff --git a/box/models/ebics_user.rb b/box/models/ebics_user.rb
new file mode 100644
index 00000000..230cc37d
--- /dev/null
+++ b/box/models/ebics_user.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require "epics"
+require "sequel"
+
+require_relative "../adapters/fake"
+require_relative "../adapters/file"
+require_relative "../models/event"
+require_relative "../queue"
+
+module Box
+ class EbicsUser < Sequel::Model
+ self.raise_on_save_failure = true
+
+ IncompleteEbicsData = Class.new(StandardError)
+
+ many_to_many :accounts
+ many_to_one :user
+
+ def as_event_payload
+ {
+ account_id: first_account.id,
+ user_id: user.id,
+ ebics_user: remote_user_id,
+ ebics_user_id: id,
+ signature_class: signature_class
+ }
+ end
+
+ def active?
+ !!activated_at
+ end
+
+ def ebics_data?
+ [remote_user_id, first_account.url, first_account.partner, first_account.host].all?(&:present?)
+ end
+
+ def passphrase
+ Box.configuration.db_passphrase
+ end
+
+ def client
+ account = first_account
+ @client ||= account.client_adapter.new(encryption_keys, passphrase, account.url, account.host, remote_user_id, partner)
+ end
+
+ def setup!(account, reset = false)
+ return true if !ini_letter.nil? && !reset
+
+ raise(IncompleteEbicsData) unless ebics_data?
+
+ # TODO: handle exceptions
+ Box.logger.info("setting up EBICS keys for ebics_user #{id}")
+ epics = account.client_adapter.setup(passphrase, account.url, account.host, remote_user_id, account.partner)
+ self.encryption_keys = epics.send(:dump_keys)
+ save
+
+ Box.logger.info("starting EBICS key exchange for ebics_user #{id}")
+ epics.INI
+ epics.HIA
+ self.ini_letter = epics.ini_letter(account.bankname)
+ Box.logger.info("EBICS key exchange done and ini letter generated for ebics_user #{id}")
+ self.submitted_at = Time.now
+
+ save
+ rescue Epics::Error::TechnicalError, Epics::Error::BusinessError => ex
+ Box.logger.error("Failed to init ebics_user #{id}. Reason='#{ex.message}'")
+ false
+ end
+
+ def activate!
+ Box.logger.info("activating account #{id}")
+ client.HPB
+
+ self.encryption_keys = client.send(:dump_keys)
+ self.activated_at ||= Time.now
+ save
+ Box::Event.ebics_user_activated(self)
+ true
+ rescue => e
+ # TODO: show the error to the user
+ Box.logger.error("failed to activate account #{id}: #{e}")
+ false
+ end
+
+ def refresh_bank_keys!
+ Box.logger.info("Refresh bank keys for account #{id}")
+ client.HPB
+
+ self.encryption_keys = client.send(:dump_keys)
+ save
+ true
+ rescue => e
+ # TODO: show the error to the user
+ Box.logger.error("Failed to refresh bank keys for account #{id}: #{e}")
+ false
+ end
+
+ def state
+ if active?
+ "active"
+ elsif submitted_at.present?
+ "submitted"
+ elsif ebics_data?
+ "ready_to_submit"
+ else
+ "needs_ebics_data"
+ end
+ end
+
+ def first_account
+ accounts.first
+ end
+ end
+end
diff --git a/box/models/event.rb b/box/models/event.rb
new file mode 100644
index 00000000..7d3aceac
--- /dev/null
+++ b/box/models/event.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require "openssl"
+require "sequel"
+
+require_relative "../queue"
+require_relative "../models/account"
+require_relative "../models/organization"
+require_relative "webhook_delivery"
+
+module Box
+ class Event < Sequel::Model
+ SUPPORTED_TYPES = %i[
+ account_created
+ credit_created
+ credit_status_changed
+ debit_created
+ debit_status_changed
+ ebics_user_activated
+ statement_created
+ ].freeze
+
+ RETRY_THRESHOLD = 20
+
+ NoCallback = Class.new(StandardError)
+
+ one_to_many :webhook_deliveries
+ many_to_one :account
+
+ dataset_module do
+ def paginated(page, per_page)
+ limit(per_page).offset((page - 1) * per_page)
+ end
+
+ def by_organization(organization)
+ left_join(:accounts, id: :account_id)
+ .where(accounts__organization_id: organization.id)
+ .select_all(:events)
+ end
+ end
+
+ def self.respond_to_missing?(method_name, include_private = false)
+ SUPPORTED_TYPES.include?(method_name) || super
+ end
+
+ def reset_webhook_delivery
+ set(webhook_status: "pending", webhook_retries: 0).save
+
+ Queue.trigger_webhook(event_id: id)
+ end
+
+ def self.method_missing(method_name, *args, &block)
+ if SUPPORTED_TYPES.include?(method_name)
+ data = args.shift
+ data = data.as_event_payload if data.respond_to?(:as_event_payload)
+ publish(method_name, *args.unshift(data))
+ else
+ super # ignore and pass along
+ end
+ end
+
+ def self.publish(event_type, payload = {})
+ event = new(
+ type: event_type,
+ payload: Sequel.pg_json(payload.stringify_keys),
+ account_id: payload[:account_id]
+ )
+ event.save
+ Queue.trigger_webhook(event_id: event.id)
+ end
+
+ def callback_url
+ account.try(:callback_url) || raise(NoCallback)
+ end
+
+ def account
+ @account ||= Account[account_id]
+ end
+
+ def delivery_success!
+ set webhook_status: "success"
+ save
+ end
+
+ def delivery_failure!
+ set(webhook_retries: webhook_retries.to_i + 1)
+ if webhook_retries >= RETRY_THRESHOLD
+ set(webhook_status: "failed")
+ else
+ Queue.trigger_webhook({event_id: id}, delay: delay_for(webhook_retries))
+ end
+ save
+ end
+
+ def delay_for(attempt)
+ 5 * ((attempt - 1)**2)
+ end
+
+ def to_webhook_payload
+ payload.merge(action: type, triggered_at: triggered_at)
+ end
+ end
+end
diff --git a/box/models/organization.rb b/box/models/organization.rb
new file mode 100644
index 00000000..b67210fb
--- /dev/null
+++ b/box/models/organization.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "sequel"
+require "securerandom"
+
+require_relative "account"
+
+module Box
+ class Organization < Sequel::Model
+ self.raise_on_save_failure = true
+ unrestrict_primary_key
+
+ one_to_many :accounts
+ one_to_many :users
+
+ def self.find_by_management_token(token)
+ return unless token
+
+ first(management_token: token)
+ end
+
+ def self.register(params)
+ orga = new(params)
+ orga.webhook_token ||= SecureRandom.hex
+ orga.save
+ end
+
+ def events
+ accounts_dataset.left_join(:events)
+ end
+
+ def find_account!(iban)
+ accounts_dataset.first!(iban: iban)
+ rescue Sequel::NoMatchingRow => _ex
+ raise Account::NotFound.for_orga(organization_id: id, iban: iban)
+ end
+ end
+end
diff --git a/box/models/statement.rb b/box/models/statement.rb
new file mode 100644
index 00000000..256af97a
--- /dev/null
+++ b/box/models/statement.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require "json"
+require "sequel"
+
+require_relative "../models/account"
+require_relative "../models/bank_statement"
+require_relative "../models/transaction"
+require_relative "../entities/statement"
+
+module Box
+ class Statement < Sequel::Model
+ many_to_one :account
+ many_to_one :bank_statement
+ many_to_one :transaction
+
+ dataset_module do
+ def by_organization(organization)
+ left_join(:accounts, id: :account_id)
+ .where(accounts__organization_id: organization.id)
+ .select_all(:statements)
+ end
+
+ def filtered(params)
+ query = self
+
+ # Filter by account id
+ query = query.where(accounts__iban: params[:iban]) if params[:iban].present?
+
+ # Filter by statement date
+ query = query.where { statements__date >= params[:from] } if params[:from].present?
+ query = query.where { statements__date <= params[:to] } if params[:to].present?
+
+ # Filter by eref
+ query = query.where(eref: params[:end_to_end_reference]) if params[:end_to_end_reference].present?
+
+ # Filter by type
+ query = query.where(debit: params[:type] == "debit") if params[:type].present?
+
+ query
+ end
+
+ def paginate(params)
+ limit(params[:per_page])
+ .offset((params[:page] - 1) * params[:per_page])
+ .reverse_order(:date, :id)
+ end
+ end
+
+ class << self
+ def generic_filter(query, account_id: nil, transaction_id: nil, from: nil, to: nil, type: nil, **_unused)
+ # Filter by account id
+ query = query.where(account_id: account_id) if account_id.present?
+
+ # Filter by transaction id
+ query = query.where(transaction_id: transaction_id) if transaction_id.present?
+
+ # Filter by statement date
+ query = query.where { statements__date >= from } if from.present?
+ query = query.where { statements__date <= to } if to.present?
+
+ # Filter by type
+ query = query.where(debit: type == "debit") if type.present?
+
+ query
+ end
+
+ def count_by_account(**generic_filters)
+ query = generic_filter(self, generic_filters)
+ query.count
+ end
+
+ def paginated_by_account(per_page: 10, page: 1, **generic_filters)
+ query = limit(per_page).offset((page - 1) * per_page).reverse_order(:date, :id)
+ generic_filter(query, **generic_filters)
+ end
+ end
+
+ def credit?
+ !debit?
+ end
+
+ def debit?
+ debit
+ end
+
+ def type
+ debit? ? "debit" : "credit"
+ end
+
+ def amount_in_cents
+ amount
+ end
+
+ def as_event_payload
+ {
+ id: public_id,
+ account_id: account_id,
+ statement: Entities::Statement.represent(self).as_json
+ }
+ end
+ end
+end
diff --git a/box/models/transaction.rb b/box/models/transaction.rb
new file mode 100644
index 00000000..cc20187d
--- /dev/null
+++ b/box/models/transaction.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require "sequel"
+
+require_relative "../../lib/pain"
+
+require_relative "account"
+require_relative "event"
+require_relative "statement"
+require_relative "user"
+
+module Box
+ class Transaction < Sequel::Model
+ ID_REGEX = Regexp.new('([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}?)', Regexp::IGNORECASE)
+
+ plugin :dirty
+
+ many_to_one :account
+ many_to_one :user
+ one_to_many :statements
+
+ dataset_module do
+ where(:credit_transfers, type: "credit")
+ where(:direct_debits, type: "debit")
+
+ def by_organization(organization)
+ left_join(:accounts, id: :account_id)
+ .where(accounts__organization_id: organization.id)
+ .select_all(:transactions)
+ end
+
+ def filtered(params)
+ query = self
+
+ # Filter by account iban, status
+ query = query.where(accounts__iban: params[:iban]) if params[:iban].present?
+ query = query.where(status: params[:status]) if params[:status].present?
+
+ query
+ end
+
+ def paginate(params)
+ limit(params[:per_page])
+ .offset((params[:page] - 1) * params[:per_page])
+ .reverse_order(:id)
+ end
+ end
+
+ def self.count_by_account(account_id, _options = {})
+ where(account_id: account_id).count
+ end
+
+ def self.paginated_by_account(account_id, options = {})
+ options = {per_page: 10, page: 1}.merge(options)
+ where(account_id: account_id).limit(options[:per_page]).offset((options[:page] - 1) * options[:per_page]).reverse_order(:id)
+ end
+
+ def history
+ super || []
+ end
+
+ def make_history(reason, status = self.status)
+ update(history: history.dup << {at: Time.now, status: status, reason: reason})
+ end
+
+ def update_status(new_status, reason: nil)
+ self.status = get_status(new_status)
+
+ if column_changed?(:status)
+ make_history(reason, status)
+ Event.method("#{type}_status_changed").call(self)
+ end
+
+ status
+ end
+
+ def get_status(new_status)
+ if new_status == "file_upload" && status == "created" then "file_upload"
+ elsif new_status == "es_verification" && status == "file_upload" then "es_verification"
+ elsif new_status == "order_hac_final_pos" && status == "es_verification" then "order_hac_final_pos"
+ elsif new_status == "order_hac_final_neg" && status == "es_verification" then "order_hac_final_neg"
+ elsif new_status == "credit_received" && type == "debit" then "funds_credited"
+ elsif new_status == "debit_received" && type == "credit" then "funds_debited"
+ elsif new_status == "debit_received" && type == "debit" then "funds_charged_back"
+ elsif new_status == "failed" then "failed"
+ else
+ status
+ end
+ end
+
+ def execute!
+ return if ebics_transaction_id.present?
+
+ transaction_id, order_id = account.client_for(user.id).public_send(order_type, payload)
+ update(ebics_order_id: order_id, ebics_transaction_id: transaction_id)
+ rescue Epics::Error => e
+ Box.logger.warn { "Could not execute payload for transaction. id=#{id} message=#{e.message}" }
+ update_status("failed", reason: "#{e.code}/#{e.message}")
+ rescue Faraday::Error => e
+ Box.logger.warn { "Request failed. id=#{id} message=#{e.message}" }
+ make_history(e.message)
+ raise(e)
+ rescue => e
+ Box.logger.warn { "Request failed. id=#{id} message=#{e.message}" }
+ make_history(e.message)
+ end
+
+ def parsed_payload
+ @parsed_payload ||= Pain.from_xml(payload).to_h
+ rescue Pain::UnknownInput => _ex
+ Box.logger.warn { "Could not parse payload for transaction. id=#{id}" }
+ nil
+ end
+
+ def as_event_payload
+ {
+ id: public_id,
+ account_id: account_id,
+ transaction: {
+ id: id,
+ eref: eref,
+ type: type,
+ status: status,
+ ebics_order_id: ebics_order_id,
+ ebics_transaction_id: ebics_transaction_id
+ }
+ }
+ end
+ end
+end
diff --git a/box/models/user.rb b/box/models/user.rb
new file mode 100644
index 00000000..c30f1583
--- /dev/null
+++ b/box/models/user.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "sequel"
+
+module Box
+ class User < Sequel::Model
+ self.raise_on_save_failure = true
+ unrestrict_primary_key
+
+ many_to_one :organization
+ one_to_many :ebics_users
+
+ def before_create
+ super
+ self.access_token ||= SecureRandom.hex(32) unless access_token.present?
+ end
+
+ def self.find_by_access_token(access_token)
+ return unless access_token
+
+ first(access_token: access_token)
+ end
+ end
+end
diff --git a/box/models/webhook_delivery.rb b/box/models/webhook_delivery.rb
new file mode 100644
index 00000000..68ca3064
--- /dev/null
+++ b/box/models/webhook_delivery.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require "benchmark"
+require "json"
+require "faraday"
+require "sequel"
+
+require_relative "event"
+require_relative "../middleware/signer"
+require_relative "../../config/configuration"
+
+module Box
+ class WebhookDelivery < Sequel::Model
+ many_to_one :event
+
+ def self.deliver(event)
+ new(event: event) do |delivery|
+ delivery.save
+ delivery.deliver
+ end
+ end
+
+ def deliver
+ response, execution_time = execute_request
+ set(
+ delivered_at: DateTime.now,
+ response_body: response.body,
+ reponse_headers: Sequel.pg_json(response.headers.stringify_keys),
+ response_status: response.status,
+ response_time: execution_time
+ )
+ save
+ response.success? ? event.delivery_success! : event.delivery_failure!
+ rescue Event::NoCallback => _ex
+ Box.logger.warn("[WebhookDelivery] No callback url for event. event_id=#{event.id}")
+ end
+
+ def execute_request
+ response = nil
+ execution_time = 0
+ begin
+ execution_time = Benchmark.realtime do
+ conn = build_connection(event.callback_url)
+ response = conn.post do |req|
+ req.url URI(event.callback_url).path
+ req.headers["Content-Type"] = "application/json"
+ payload = event.to_webhook_payload.to_json
+ req.body = Box.configuration.encrypt_webhooks? ? encrypt(payload) : payload
+ end
+ end
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::Error => ex
+ Box.logger.warn("[WebhookDelivery] Failed for event_id=#{event.id}: #{ex.message}")
+ response = FailedResponse.new(ex.message)
+ end
+ [response, execution_time]
+ end
+
+ def encrypt(payload)
+ # AES encryption
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
+ aes_key = cipher.random_key
+ cipher.encrypt
+ cipher.key = aes_key
+ iv = cipher.random_iv
+ encrypted_payload = cipher.update(payload) + cipher.final
+
+ # Combine IV and encrypted payload
+ encoded_iv = Base64.strict_encode64(iv)
+ encoded_encrypted_payload = Base64.strict_encode64(encrypted_payload)
+
+ public_key = OpenSSL::PKey::RSA.new(Base64.decode64(Box.configuration.webhook_encryption_key))
+ encoded_aes_key = Base64.strict_encode64(aes_key)
+ encrypted_encoded_aes_key = public_key.public_encrypt(encoded_aes_key)
+ encoded_encrypted_encoded_aes_key = Base64.strict_encode64(encrypted_encoded_aes_key)
+ "#{encoded_encrypted_encoded_aes_key}$#{encoded_iv}$#{encoded_encrypted_payload}"
+ end
+
+ class FailedResponse
+ def initialize(message)
+ @message = message
+ end
+
+ def success?
+ false
+ end
+
+ def body
+ @message
+ end
+
+ def headers
+ {}
+ end
+
+ def status
+ 0
+ end
+ end
+
+ private
+
+ def extract_auth(url)
+ url.match(%r{://(.*):(.*)@})&.captures
+ end
+
+ def build_connection(callback_url)
+ auth = extract_auth(callback_url)
+ uri = URI(callback_url)
+ Faraday.new("#{uri.scheme}://#{uri.host}") do |conn|
+ conn.request :basic_auth, *auth if auth
+ conn.request :signer, secret: event.account.organization.webhook_token
+ conn.adapter Faraday.default_adapter
+ end
+ end
+ end
+end
diff --git a/box/queue.rb b/box/queue.rb
new file mode 100644
index 00000000..9f1d32b8
--- /dev/null
+++ b/box/queue.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require "sidekiq"
+require "sidekiq/api"
+require "active_support/core_ext/array"
+
+require_relative "jobs/credit"
+require_relative "jobs/debit"
+require_relative "jobs/queue_fetch_statements"
+require_relative "jobs/queue_fetch_upcoming_statements"
+require_relative "jobs/queue_processing_status"
+require_relative "jobs/fetch_processing_status"
+require_relative "jobs/fetch_statements"
+require_relative "jobs/fetch_upcoming_statements"
+require_relative "jobs/webhook"
+require_relative "jobs/check_activation"
+
+module Box
+ class Queue
+ def self.clear!(queue)
+ Sidekiq::Queue.new(queue).clear
+ end
+
+ def self.update_processing_status(account_ids = nil, delay = Box.configuration.hac_retrieval_interval.seconds)
+ account_ids ||= Account.all_active_ids
+
+ # do not schedule job if already scheduled
+ return if Sidekiq::ScheduledSet.new.any? { |j| j.item["class"] == Jobs::QueueProcessingStatus.name }
+
+ Jobs::QueueProcessingStatus.perform_in(delay, Array.wrap(account_ids))
+ end
+
+ def self.fetch_account_statements(account_ids = nil)
+ Jobs::QueueFetchStatements.perform_async(Array.wrap(account_ids))
+ end
+
+ def self.trigger_webhook(payload, options = {})
+ delay = options.fetch(:delay, 0)
+ Jobs::Webhook.perform_in(delay, payload[:event_id])
+ end
+
+ def self.execute_credit(payload)
+ Jobs::Credit.perform_async(payload.deep_stringify_keys)
+ end
+
+ def self.execute_debit(payload)
+ Jobs::Debit.perform_async(payload.deep_stringify_keys)
+ end
+ end
+end
diff --git a/box/validations/active_account.rb b/box/validations/active_account.rb
new file mode 100644
index 00000000..beea8e15
--- /dev/null
+++ b/box/validations/active_account.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../models/account"
+
+module Box
+ class ActiveAccount < Grape::Validations::Validators::Base
+ def validate_param!(attr_name, params)
+ account = Account.first!(iban: params[:id])
+ if account.iban != params[:iban] && account.active?
+ raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: "cannot be changed on active account"
+ end
+ end
+ end
+end
diff --git a/box/validations/length.rb b/box/validations/length.rb
new file mode 100644
index 00000000..8ca4cf93
--- /dev/null
+++ b/box/validations/length.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "grape"
+
+class Length < Grape::Validations::Validators::Base
+ def validate_param!(attr_name, params)
+ unless params[attr_name].length <= @option
+ raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: "must be at the most #{@option} characters long")
+ end
+ end
+end
diff --git a/box/validations/unique_account.rb b/box/validations/unique_account.rb
new file mode 100644
index 00000000..4e412927
--- /dev/null
+++ b/box/validations/unique_account.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../models/account"
+
+module Box
+ class UniqueAccount < Grape::Validations::Validators::Base
+ def validate(request)
+ organization = request.env["box.organization"]
+ if request.post? && !organization.accounts_dataset.where(iban: request.params[:iban]).empty?
+ raise Grape::Exceptions::Validation.new(params: [:iban], message: "must be unique")
+ end
+ end
+ end
+end
diff --git a/box/validations/unique_ebics_user.rb b/box/validations/unique_ebics_user.rb
new file mode 100644
index 00000000..e71bd973
--- /dev/null
+++ b/box/validations/unique_ebics_user.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../models/ebics_user"
+
+module Box
+ class UniqueEbicsUser < Grape::Validations::Validators::Base
+ def validate_param!(attr_name, params)
+ scope = EbicsUser.association_join(:accounts)
+ .where(
+ accounts__iban: params[:iban],
+ user_id: params[:user_id],
+ remote_user_id: params[:ebics_user]
+ )
+
+ return if scope.none?
+
+ raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: "already setup for given account")
+ end
+ end
+end
diff --git a/box/validations/unique_transaction_eref.rb b/box/validations/unique_transaction_eref.rb
new file mode 100644
index 00000000..33ed5a6f
--- /dev/null
+++ b/box/validations/unique_transaction_eref.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "grape"
+
+require_relative "../models/transaction"
+
+module Box
+ class UniqueTransactionEref < Grape::Validations::Validators::Base
+ def validate(request)
+ organization = request.env["box.organization"]
+
+ eref_unused = organization
+ .accounts_dataset.where(iban: request.params[:account])
+ .left_join(:transactions, account_id: :id).where(transactions__eref: request.params[:end_to_end_reference])
+ .empty?
+
+ raise Grape::Exceptions::Validation.new(params: [@scope.full_name(:end_to_end_reference)], message: "must be unique") unless eref_unused
+ end
+ end
+
+ class LengthTransactionEref < Grape::Validations::Validators::Base
+ def length(currency)
+ Hash.new(27).update(
+ "EUR" => 64
+ )[currency]
+ end
+
+ def validate(request)
+ return if request.params[:end_to_end_reference].to_s.size <= length(request.params[:currency])
+
+ raise Grape::Exceptions::Validation.new(params: [@scope.full_name(:end_to_end_reference)], message: "must be at the most #{length(request.params[:currency])} characters long")
+ end
+ end
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 00000000..d7936665
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require_relative "config/bootstrap"
+
+if ENV["SENTRY_DSN"]
+ require "sentry-ruby"
+ Sentry.init do |config|
+ # Raven reports on the following environments
+ config.enabled_environments = %w[development staging production]
+ end
+
+ use Sentry::Rack::CaptureExceptions
+end
+
+if ENV["ROLLBAR_ACCESS_TOKEN"]
+ require "rollbar/middleware/rack"
+ Rollbar.configure do |config|
+ config.access_token = ENV["ROLLBAR_ACCESS_TOKEN"]
+ config.environment = ENV["RACK_ENV"]
+ end
+
+ use Rollbar::Middleware::Rack
+end
+
+if ENV["RACK_ENV"] == "production"
+ unless ENV["DISABLE_SSL_FORCE"]
+ require "rack/ssl-enforcer"
+ use Rack::SslEnforcer, except: ["/health", "/setup"]
+ end
+
+ # Log all requests in apache log file format
+ use Rack::CommonLogger
+end
+
+# check env vars
+Box.configuration.valid?
+
+# Load database connection validator middleware
+require_relative "box/middleware/connection_validator"
+use Box::Middleware::ConnectionValidator, DB
+
+# Load authentication middleware
+use Box.configuration.auth_provider
+
+# Enable CORS to enable access to our API from frontend apps and our swagger documentation
+require "rack/cors"
+use Rack::Cors do
+ allow do
+ origins "*"
+ resource "*", headers: :any, methods: [:get, :post, :put, :delete, :options]
+ end
+end
+
+# Deliver assets
+use Rack::Static, urls: [
+ "/swagger-ui-standalone-preset.js",
+ "/swagger-ui-bundle.js",
+ "/swagger-ui-standalone-preset.js",
+ "/swagger-ui.css",
+ "/swagger-ui.js",
+ "/doc/swagger-v1.json",
+ "/doc/swagger-v2.json"
+], root: "public/swagger"
+
+# Deliver html/json documentation template
+map "/docs" do
+ run lambda { |env|
+ [
+ 200,
+ {
+ "Content-Type" => "text/html",
+ "Cache-Control" => "public, max-age=86400"
+ },
+ File.open("public/swagger/index.html", File::RDONLY)
+ ]
+ }
+end
+
+# Finally, load application and all its endpoints
+require_relative "box/apis/base"
+run Box::Apis::Base
diff --git a/config/bootstrap.rb b/config/bootstrap.rb
new file mode 100644
index 00000000..5b1ef5e3
--- /dev/null
+++ b/config/bootstrap.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+#
+# This script should be loaded by all entrypoints as it sets up out app's namespace and handles
+# our configuration. Moreover it ensures that the database is setup.
+#
+
+require "logger"
+require "sequel"
+require "barnes"
+
+require_relative "configuration"
+require_relative "version"
+
+# Setup box namespace
+module Box
+ def self.configuration
+ @configuration ||= Configuration.new
+ end
+
+ def self.logger
+ @logger ||= Logger.new($stdout).tap do |logger|
+ logger.level = ENV["DEBUG"] ? Logger::DEBUG : Logger::INFO
+ end
+ end
+
+ def self.logger=(logger)
+ @logger = logger
+ end
+end
+
+# Init database connection
+DB = Sequel.connect(Box.configuration.database_url, max_connections: 10)
+
+# enable histoic symbol splitting to create qualified and/or aliased identifiers
+# https://github.com/jeremyevans/sequel/blob/master/doc/release_notes/5.0.0.txt#L18
+# ToDo: update code to support default and disable split_symbols
+Sequel.split_symbols = true
+# Enable json extensions
+Sequel::Model.plugin :timestamps, update_on_create: true
+Sequel.extension :pg_json
+DB.extension :pg_json
+
+Barnes.start
diff --git a/config/configuration.rb b/config/configuration.rb
new file mode 100644
index 00000000..ee813178
--- /dev/null
+++ b/config/configuration.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+env = ENV.fetch("RACK_ENV", "production")
+if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+end
+
+module Box
+ class ConfigurationError < StandardError; end
+
+ class Configuration
+ def app_url
+ ENV["APP_URL"] || "http://localhost:5000"
+ end
+
+ def database_url
+ return ENV["TEST_DATABASE_URL"] || "postgres://localhost/ebicsbox_test" if test?
+
+ ENV["DATABASE_URL"] || "postgres://localhost/ebicsbox"
+ end
+
+ def hac_retrieval_interval
+ 120 # seconds
+ end
+
+ def ebics_client
+ (ENV["EBICS_CLIENT"] || "Epics::Client").constantize
+ end
+
+ def db_passphrase
+ ENV.fetch("PASSPHRASE")
+ rescue KeyError
+ raise ConfigurationError, "PASSPHRASE missing"
+ end
+
+ def test?
+ ENV["RACK_ENV"] == "test"
+ end
+
+ def sandbox?
+ ENV["SANDBOX"] == "enabled"
+ end
+
+ def ui_initial_setup?
+ ENV["UI_INITIAL_SETUP"] == "enabled"
+ end
+
+ def jwt_secret
+ ENV.fetch("JWT_SECRET")
+ rescue KeyError
+ raise ConfigurationError, "JWT_SECRET missing"
+ end
+
+ def oauth_server
+ ENV["OAUTH_SERVER"] || "http://localhost:3000"
+ end
+
+ def static_auth?
+ ENV["AUTH_SERVICE"] == "static"
+ end
+
+ def auth_provider
+ if static_auth?
+ require_relative "../box/middleware/static_authentication"
+ Box::Middleware::StaticAuthentication
+ else
+ require_relative "../box/middleware/oauth_authentication"
+ Box::Middleware::OauthAuthentication
+ end
+ end
+
+ def valid?
+ # try fetching all env vars required for a smooth operation
+ db_passphrase
+
+ jwt_secret unless static_auth?
+ end
+
+ def webhook_encryption_key
+ ENV["WEBHOOK_ENCRYPTION_KEY"]
+ end
+
+ def encrypt_webhooks?
+ webhook_encryption_key != nil
+ end
+ end
+end
diff --git a/config/sidekiq.rb b/config/sidekiq.rb
new file mode 100644
index 00000000..1fed8863
--- /dev/null
+++ b/config/sidekiq.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+env = ENV.fetch("RACK_ENV", :development)
+if env.to_s != "production"
+ # Load environment from file
+ require "dotenv"
+ Dotenv.load
+end
+
+# Load environment
+require "bundler"
+Bundler.setup(:default, env)
+
+# Make sure output is written immediately
+$stdout.sync = true
+
+# Start processing of queued jobs
+require_relative "bootstrap"
+
+require_relative "../box/jobs/credit"
+require_relative "../box/jobs/debit"
+require_relative "../box/jobs/fetch_processing_status"
+require_relative "../box/jobs/fetch_statements"
+require_relative "../box/jobs/fetch_upcoming_statements"
+require_relative "../box/jobs/webhook"
+require_relative "../box/jobs/check_activation"
+
+require "sidekiq"
+require "sidekiq-scheduler"
+
+if ENV["ROLLBAR_ACCESS_TOKEN"]
+ require "rollbar"
+
+ Rollbar.configure do |config|
+ config.access_token = ENV["ROLLBAR_ACCESS_TOKEN"]
+ config.environment = ENV["RACK_ENV"]
+ config.use_sidekiq
+ end
+end
+
+if ENV["SENTRY_DSN"]
+ require "sentry-ruby"
+ require "sentry-sidekiq"
+ Sentry.init do |config|
+ # Raven reports on the following environments
+ config.enabled_environments = %w[development staging production]
+ end
+end
+
+Sidekiq.configure_server do |config|
+ config.on(:startup) do
+ if ENV.key?("UPDATE_BANK_STATEMENTS_INTERVAL")
+ fetch_bank_statements_interval = ENV["UPDATE_BANK_STATEMENTS_INTERVAL"].to_i
+ if fetch_bank_statements_interval.zero?
+ Sidekiq.remove_schedule(:fetch_account_statements)
+ else
+ Sidekiq.set_schedule(
+ :fetch_account_statements,
+ every: "#{fetch_bank_statements_interval}m",
+ class: "Box::Jobs::QueueFetchStatements",
+ queue: "check.statements"
+ )
+ end
+ end
+
+ if ENV.key?("UPDATE_PROCESSING_STATUS_INTERVAL")
+ update_processing_status_interval = ENV["UPDATE_PROCESSING_STATUS_INTERVAL"].to_i
+ if update_processing_status_interval.zero?
+ Sidekiq.remove_schedule(:update_processing_status)
+ else
+ Sidekiq.set_schedule(
+ :update_processing_status,
+ every: "#{update_processing_status_interval}m",
+ class: "Box::Jobs::QueueProcessingStatus",
+ queue: "check.orders"
+ )
+ end
+ end
+
+ if ENV.key?("ACTIVATE_EBICS_USER_INTERVAL")
+ activate_ebics_user_interval = ENV["ACTIVATE_EBICS_USER_INTERVAL"].to_i
+ if activate_ebics_user_interval.zero?
+ Sidekiq.remove_schedule(:activate_ebics_user)
+ else
+ Sidekiq.set_schedule(
+ :activate_ebics_user,
+ every: "#{activate_ebics_user_interval}m",
+ class: "Box::Jobs::CheckActivation",
+ queue: "check.activations"
+ )
+ end
+ end
+
+ if ENV.key?("UPCOMING_STATEMENTS_INTERVAL")
+ upcoming_statements_interval = ENV["UPCOMING_STATEMENTS_INTERVAL"].to_i
+ if upcoming_statements_interval.zero?
+ Sidekiq.remove_schedule(:fetch_upcoming_account_statements)
+ else
+ Sidekiq.set_schedule(
+ :fetch_upcoming_account_statements,
+ every: "#{upcoming_statements_interval}m",
+ class: "Box::Jobs::QueueFetchUpcomingStatements",
+ queue: "check.statements"
+ )
+ end
+ end
+ end
+end
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
new file mode 100644
index 00000000..9e29e22f
--- /dev/null
+++ b/config/sidekiq.yml
@@ -0,0 +1,28 @@
+---
+concurrency: 3
+queues:
+ - "debit"
+ - "credit"
+ - "webhooks"
+ - "check.statements"
+ - "check.orders"
+ - "check.activations"
+ - "rollbar"
+schedule:
+ update_processing_status:
+ every: "6h"
+ class: Box::Jobs::QueueProcessingStatus
+ queue: "check.orders"
+ fetch_account_statements:
+ every: "60m"
+ class: Box::Jobs::QueueFetchStatements
+ queue: "check.statements"
+ activate_ebics_user:
+ every: "60m"
+ class: Box::Jobs::CheckActivation
+ queue: "check.activations"
+ fetch_upcoming_account_statements:
+ every: "60m"
+ class: Box::Jobs::QueueFetchUpcomingStatements
+ queue: "check.statements"
+dynamic: true
diff --git a/config/version.rb b/config/version.rb
new file mode 100644
index 00000000..1a4af021
--- /dev/null
+++ b/config/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Box
+ def self.version
+ ENV["APP_VERSION"].to_s
+ end
+end
diff --git a/db/migrations/20171104144100_merged.rb b/db/migrations/20171104144100_merged.rb
new file mode 100644
index 00000000..5981d023
--- /dev/null
+++ b/db/migrations/20171104144100_merged.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ run 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
+ DB.extension :pg_json
+
+ unless tables.include?(:accounts)
+ create_table(:accounts) do
+ primary_key :id
+ String :iban, text: true
+ String :bic, text: true
+ String :creditor_identifier, text: true
+ String :name, text: true
+ String :url, text: true
+ String :host, text: true
+ String :partner, text: true
+ String :callback_url, text: true
+ String :mode, text: true
+ String :bankname, text: true
+ Integer :organization_id
+ Integer :balance_in_cents
+ Date :balance_date
+ String :statements_format, default: "mt940", text: true
+ String :config, type: :json, default: Sequel.pg_json({})
+ String :descriptor, text: true
+ end
+ end
+
+ unless tables.include?(:bank_statements)
+ create_table(:bank_statements) do
+ primary_key :id
+ Integer :account_id
+ String :remote_account, text: true
+ String :sequence, text: true
+ BigDecimal :opening_balance, size: [15, 2]
+ BigDecimal :closing_balance, size: [15, 2]
+ Integer :transaction_count
+ Date :fetched_on
+ String :content, text: true
+ Integer :year
+ end
+ end
+
+ unless tables.include?(:events)
+ create_table(:events) do
+ primary_key :id
+ Integer :account_id
+ String :type, text: true
+ String :public_id, type: :uuid, default: Sequel.function(:uuid_generate_v4)
+ String :payload, type: :json, default: Sequel.pg_json({})
+ DateTime :triggered_at, default: Sequel.function(:now)
+ String :signature, text: true
+ String :webhook_status, default: "pending", text: true
+ Integer :webhook_retries, default: 0
+ end
+ end
+
+ unless tables.include?(:imports)
+ create_table(:imports) do
+ primary_key :id
+ Date :date
+ Integer :duration
+ Integer :transactions_count
+ Integer :account_id
+ end
+ end
+
+ unless tables.include?(:organizations)
+ create_table(:organizations) do
+ primary_key :id
+ String :name, text: true
+ DateTime :created_at, default: Sequel.function(:now)
+ String :webhook_token, text: true, null: false
+ end
+ end
+
+ unless tables.include?(:statements)
+ create_table(:statements, ignore_index_errors: true) do
+ primary_key :id
+ String :sha, text: true
+ Date :date
+ Date :entry_date
+ Integer :amount
+ Integer :sign
+ TrueClass :debit
+ String :swift_code, text: true
+ String :reference, text: true
+ String :bank_reference, text: true
+ String :bic, text: true
+ String :iban, text: true
+ String :name, text: true
+ String :eref, text: true
+ String :mref, text: true
+ String :svwz, text: true
+ String :creditor_identifier, text: true
+ String :information, text: true
+ String :description, text: true
+ String :transaction_code, text: true
+ String :details, text: true
+ Integer :account_id
+ Integer :transaction_id
+ Integer :bank_statement_id
+ String :public_id, type: :uuid, null: false, default: Sequel.function(:uuid_generate_v4)
+
+ index [:sha], name: :statements_sha_key, unique: true
+ end
+ end
+
+ unless tables.include?(:subscribers)
+ create_table(:subscribers) do
+ primary_key :id
+ Integer :account_id
+ Integer :user_id
+ String :remote_user_id, text: true
+ String :encryption_keys, text: true
+ String :signature_class, size: 1
+ DateTime :created_at, default: Sequel.function(:now)
+ DateTime :activated_at
+ String :ini_letter, text: true
+ DateTime :submitted_at
+ end
+ end
+
+ unless tables.include?(:transactions)
+ create_table(:transactions, ignore_index_errors: true) do
+ primary_key :id
+ String :eref, text: true
+ String :type, text: true
+ String :payload, text: true
+ String :ebics_order_id, text: true
+ String :ebics_transaction_id, text: true
+ String :status, text: true
+ Integer :account_id
+ String :order_type, text: true
+ Integer :amount
+ Integer :user_id
+ DateTime :created_at
+ String :public_id, type: :uuid, null: false, default: Sequel.function(:uuid_generate_v4)
+ String :history, type: :json, default: Sequel.pg_json([])
+
+ index [:eref], name: :transactions_eref_key, unique: true
+ end
+ end
+
+ unless tables.include?(:users)
+ create_table(:users) do
+ primary_key :id
+ Integer :organization_id
+ String :name, text: true
+ String :access_token, text: true
+ DateTime :created_at, default: Sequel.function(:now)
+ TrueClass :admin, default: false
+ String :email, text: true
+ end
+ end
+
+ unless tables.include?(:webhook_deliveries)
+ create_table(:webhook_deliveries) do
+ primary_key :id
+ Integer :event_id
+ DateTime :delivered_at
+ String :response_body, text: true
+ String :reponse_headers, type: :json, default: Sequel.pg_json({})
+ Integer :response_status
+ Integer :response_time
+ end
+ end
+ end
+end
diff --git a/db/migrations/20180316144100_add_foreign_credit_support.rb b/db/migrations/20180316144100_add_foreign_credit_support.rb
new file mode 100644
index 00000000..27e90662
--- /dev/null
+++ b/db/migrations/20180316144100_add_foreign_credit_support.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ add_column :transactions, :currency, String, default: "EUR"
+ add_column :transactions, :metadata, String, type: :json, default: Sequel.pg_json({})
+ end
+end
diff --git a/db/migrations/20180323132359_add_default_user_and_ogranisation.rb b/db/migrations/20180323132359_add_default_user_and_ogranisation.rb
new file mode 100644
index 00000000..d22c7d9c
--- /dev/null
+++ b/db/migrations/20180323132359_add_default_user_and_ogranisation.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ self[:organizations].insert(name: "Primary Organization", webhook_token: SecureRandom.hex(32)) unless self[:organizations].any?
+
+ if self[:users].empty?
+ orga_id = self[:organizations].first[:id]
+ token = SecureRandom.hex(32)
+ self[:users].insert(organization_id: orga_id, name: "Primary user", admin: true, access_token: token)
+ end
+ end
+end
diff --git a/db/migrations/20190211161038_renames_subscribers_to_ebics_users.rb b/db/migrations/20190211161038_renames_subscribers_to_ebics_users.rb
new file mode 100644
index 00000000..dd5aa0e4
--- /dev/null
+++ b/db/migrations/20190211161038_renames_subscribers_to_ebics_users.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ rename_table :subscribers, :ebics_users
+ end
+
+ down do
+ rename_table :ebics_users, :subscribers
+ end
+end
diff --git a/db/migrations/20190520080200_fixes_public_id_generation_of_statements.rb b/db/migrations/20190520080200_fixes_public_id_generation_of_statements.rb
new file mode 100644
index 00000000..71e8db0f
--- /dev/null
+++ b/db/migrations/20190520080200_fixes_public_id_generation_of_statements.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ if from(:statements).select(:public_id).all? { |s| s[:public_id].nil? }
+ # if all are empty, drop column and re-add to recalculate public-ids
+ drop_column :statements, :public_id
+ add_column :statements, :public_id, String, type: :uuid, default: Sequel.function(:uuid_generate_v4)
+ else
+ # if at least a single public-id is already set, just update the default for
+ # new records to have a correctly generated public_id
+ set_column_default :statements, :public_id, Sequel.function(:uuid_generate_v4)
+ end
+ end
+
+ down do
+ # noop
+ end
+end
diff --git a/db/migrations/20190520113139_add_accounts_ebics_users.rb b/db/migrations/20190520113139_add_accounts_ebics_users.rb
new file mode 100644
index 00000000..54eda882
--- /dev/null
+++ b/db/migrations/20190520113139_add_accounts_ebics_users.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ create_table(:accounts_ebics_users) do
+ Integer :account_id
+ Integer :ebics_user_id
+ DateTime :created_at, default: Sequel.function(:now)
+ end
+
+ self[:ebics_users].each do |ebics_user|
+ self[:accounts_ebics_users].insert(account_id: ebics_user[:account_id], ebics_user_id: ebics_user[:id])
+ end
+ end
+
+ down do
+ drop_table :accounts_ebics_users
+ end
+end
diff --git a/db/migrations/20190529173000_add_sha_to_bank_statement.rb b/db/migrations/20190529173000_add_sha_to_bank_statement.rb
new file mode 100644
index 00000000..7f5c043a
--- /dev/null
+++ b/db/migrations/20190529173000_add_sha_to_bank_statement.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ add_column :bank_statements, :sha, String
+ add_index :bank_statements, :sha
+
+ require "rake"
+ load "Rakefile"
+ Rake::Task["migration_tasks:calculate_bank_statements_sha"].invoke
+ end
+
+ down do
+ drop_column :bank_statements, :sha
+ end
+end
diff --git a/db/migrations/20190607145622_add_format_to_statement.rb b/db/migrations/20190607145622_add_format_to_statement.rb
new file mode 100644
index 00000000..f75d5f72
--- /dev/null
+++ b/db/migrations/20190607145622_add_format_to_statement.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ add_column :statements, :settled, :boolean, default: true
+ end
+
+ down do
+ drop_column :statements, :settled
+ end
+end
diff --git a/db/migrations/20190705151229_remove_acocunt_id_from_ebics_user.rb b/db/migrations/20190705151229_remove_acocunt_id_from_ebics_user.rb
new file mode 100644
index 00000000..ecdfe72e
--- /dev/null
+++ b/db/migrations/20190705151229_remove_acocunt_id_from_ebics_user.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ drop_column :ebics_users, :account_id
+ end
+
+ down do
+ add_column :ebics_users, :account_id, Integer
+ end
+end
diff --git a/db/migrations/20190705151345_adds_partner_ebics_users.rb b/db/migrations/20190705151345_adds_partner_ebics_users.rb
new file mode 100644
index 00000000..f334eef3
--- /dev/null
+++ b/db/migrations/20190705151345_adds_partner_ebics_users.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ add_column :ebics_users, :partner, String
+
+ require "rake"
+ load "Rakefile"
+ Rake::Task["migration_tasks:copy_partners"].invoke
+ end
+
+ down do
+ drop_column :ebics_users, :partner
+ end
+end
diff --git a/db/migrations/20191105154112_truncate_statement_sha_column.rb b/db/migrations/20191105154112_truncate_statement_sha_column.rb
new file mode 100644
index 00000000..45d45e92
--- /dev/null
+++ b/db/migrations/20191105154112_truncate_statement_sha_column.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# this is an old and obsolete migration
+Sequel.migration do
+ up do
+ # noop
+ end
+
+ down do
+ # noop
+ end
+end
diff --git a/db/migrations/20191217114900_recalculate_statement_sha.rb b/db/migrations/20191217114900_recalculate_statement_sha.rb
new file mode 100644
index 00000000..edc53cd0
--- /dev/null
+++ b/db/migrations/20191217114900_recalculate_statement_sha.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ alter_table :statements do
+ add_column :sha2, :text
+ add_index :sha2, unique: true
+ end
+
+ require "rake"
+ load "Rakefile"
+
+ Rake::Task["migration_tasks:calculate_new_sha"].invoke
+
+ alter_table :statements do
+ rename_column :sha, :sha_bak
+ rename_column :sha2, :sha
+ end
+ end
+
+ down do
+ alter_table :statements do
+ drop_column :sha
+ rename_column :sha_bak, :sha
+ end
+ end
+end
diff --git a/db/migrations/20200124163800_add_tx_id_to_statements.rb b/db/migrations/20200124163800_add_tx_id_to_statements.rb
new file mode 100644
index 00000000..d8f353b7
--- /dev/null
+++ b/db/migrations/20200124163800_add_tx_id_to_statements.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ add_column :statements, :tx_id, String
+ end
+
+ down do
+ drop_column :statements, :tx_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 00000000..c13bdb46
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,166 @@
+Sequel.migration do
+ change do
+ create_table(:accounts) do
+ primary_key :id
+ column :iban, "text"
+ column :bic, "text"
+ column :creditor_identifier, "text"
+ column :name, "text"
+ column :url, "text"
+ column :host, "text"
+ column :partner, "text"
+ column :callback_url, "text"
+ column :mode, "text"
+ column :bankname, "text"
+ column :organization_id, "integer"
+ column :balance_in_cents, "integer"
+ column :balance_date, "date"
+ column :statements_format, "text", :default=>"mt940"
+ column :config, "json", :default=>Sequel::LiteralString.new("'{}'::json")
+ column :descriptor, "text"
+ end
+
+ create_table(:accounts_ebics_users) do
+ column :account_id, "integer"
+ column :ebics_user_id, "integer"
+ column :created_at, "timestamp without time zone", :default=>Sequel::CURRENT_TIMESTAMP
+ end
+
+ create_table(:bank_statements) do
+ primary_key :id
+ column :account_id, "integer"
+ column :remote_account, "text"
+ column :sequence, "text"
+ column :opening_balance, "numeric(15,2)"
+ column :closing_balance, "numeric(15,2)"
+ column :transaction_count, "integer"
+ column :fetched_on, "date"
+ column :content, "text"
+ column :year, "integer"
+ column :sha, "text"
+
+ index [:sha]
+ end
+
+ create_table(:ebics_users) do
+ primary_key :id
+ column :user_id, "integer"
+ column :remote_user_id, "text"
+ column :encryption_keys, "text"
+ column :signature_class, "character varying(1)"
+ column :created_at, "timestamp without time zone", :default=>Sequel::CURRENT_TIMESTAMP
+ column :activated_at, "timestamp without time zone"
+ column :ini_letter, "text"
+ column :submitted_at, "timestamp without time zone"
+ column :partner, "text"
+ end
+
+ create_table(:events) do
+ primary_key :id
+ column :account_id, "integer"
+ column :type, "text"
+ column :public_id, "uuid", :default=>Sequel::LiteralString.new("uuid_generate_v4()")
+ column :payload, "json", :default=>Sequel::LiteralString.new("'{}'::json")
+ column :triggered_at, "timestamp without time zone", :default=>Sequel::CURRENT_TIMESTAMP
+ column :signature, "text"
+ column :webhook_status, "text", :default=>"pending"
+ column :webhook_retries, "integer", :default=>0
+ end
+
+ create_table(:imports) do
+ primary_key :id
+ column :date, "date"
+ column :duration, "integer"
+ column :transactions_count, "integer"
+ column :account_id, "integer"
+ end
+
+ create_table(:organizations) do
+ primary_key :id
+ column :name, "text"
+ column :created_at, "timestamp without time zone", :default=>Sequel::CURRENT_TIMESTAMP
+ column :webhook_token, "text", :null=>false
+ end
+
+ create_table(:schema_migrations) do
+ column :filename, "text", :null=>false
+
+ primary_key [:filename]
+ end
+
+ create_table(:statements) do
+ primary_key :id
+ column :sha_bak, "text"
+ column :date, "date"
+ column :entry_date, "date"
+ column :amount, "integer"
+ column :sign, "integer"
+ column :debit, "boolean"
+ column :swift_code, "text"
+ column :reference, "text"
+ column :bank_reference, "text"
+ column :bic, "text"
+ column :iban, "text"
+ column :name, "text"
+ column :eref, "text"
+ column :mref, "text"
+ column :svwz, "text"
+ column :creditor_identifier, "text"
+ column :information, "text"
+ column :description, "text"
+ column :transaction_code, "text"
+ column :details, "text"
+ column :account_id, "integer"
+ column :transaction_id, "integer"
+ column :bank_statement_id, "integer"
+ column :public_id, "uuid", :default=>Sequel::LiteralString.new("uuid_generate_v4()")
+ column :settled, "boolean", :default=>true
+ column :sha, "text"
+ column :tx_id, "text"
+
+ index [:sha], :name=>:statements_sha2_index, :unique=>true
+ index [:sha_bak], :name=>:statements_sha_key, :unique=>true
+ end
+
+ create_table(:transactions) do
+ primary_key :id
+ column :eref, "text"
+ column :type, "text"
+ column :payload, "text"
+ column :ebics_order_id, "text"
+ column :ebics_transaction_id, "text"
+ column :status, "text"
+ column :account_id, "integer"
+ column :order_type, "text"
+ column :amount, "integer"
+ column :user_id, "integer"
+ column :created_at, "timestamp without time zone"
+ column :public_id, "uuid", :default=>Sequel::LiteralString.new("uuid_generate_v4()"), :null=>false
+ column :history, "json", :default=>Sequel::LiteralString.new("'[]'::json")
+ column :currency, "text", :default=>"EUR"
+ column :metadata, "json", :default=>Sequel::LiteralString.new("'{}'::json")
+
+ index [:eref], :name=>:transactions_eref_key, :unique=>true
+ end
+
+ create_table(:users) do
+ primary_key :id
+ column :organization_id, "integer"
+ column :name, "text"
+ column :access_token, "text"
+ column :created_at, "timestamp without time zone", :default=>Sequel::CURRENT_TIMESTAMP
+ column :admin, "boolean", :default=>false
+ column :email, "text"
+ end
+
+ create_table(:webhook_deliveries) do
+ primary_key :id
+ column :event_id, "integer"
+ column :delivered_at, "timestamp without time zone"
+ column :response_body, "text"
+ column :reponse_headers, "json", :default=>Sequel::LiteralString.new("'{}'::json")
+ column :response_status, "integer"
+ column :response_time, "integer"
+ end
+ end
+end
diff --git a/docker-compose.with_db.yml b/docker-compose.with_db.yml
new file mode 100644
index 00000000..c888b13a
--- /dev/null
+++ b/docker-compose.with_db.yml
@@ -0,0 +1,97 @@
+version: "3.7"
+networks:
+ ebicsbox_network:
+services:
+ redis:
+ image: redis
+ restart: always
+ networks:
+ - ebicsbox_network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ db:
+ image: postgres
+ restart: always
+ # set shared memory limit when using docker-compose
+ shm_size: 128mb
+ environment:
+ - POSTGRES_DB=ebicsbox
+ - POSTGRES_USER=ebicsbox
+ - POSTGRES_PASSWORD=password
+ networks:
+ - ebicsbox_network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready", "-U", "postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ db_test:
+ image: postgres
+ restart: always
+ # set shared memory limit when using docker-compose
+ shm_size: 128mb
+ environment:
+ - POSTGRES_DB=ebicsbox_test
+ - POSTGRES_USER=ebicsbox_test
+ - POSTGRES_PASSWORD=password
+ networks:
+ - ebicsbox_network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready", "-U", "postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ web:
+ environment:
+ - AUTH_SERVICE=static
+ - DATABASE_URL=postgresql://ebicsbox:password@db/ebicsbox
+ - PASSPHRASE=some_passphrase
+ - PORT=5000
+ - REDIS_URL=redis://redis:6379
+ - TEST_DATABASE_URL=postgresql://ebicsbox_test:password@db_test/ebicsbox_test
+ - UI_INITIAL_SETUP=enabled
+ depends_on:
+ db:
+ condition: service_healthy
+ db_test:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - 5000:5000
+ volumes:
+ - .:/usr/ebicsbox
+ command: bin/start server
+ networks:
+ - ebicsbox_network
+ build: .
+ healthcheck:
+ test: ["CMD", bin/healthchecks/server]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ worker:
+ build: .
+ healthcheck:
+ test: ["CMD", bin/healthchecks/worker]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ environment:
+ - AUTH_SERVICE=static
+ - DATABASE_URL=postgresql://ebicsbox:password@db/ebicsbox
+ - PASSPHRASE=some_passphrase
+ - REDIS_URL=redis://redis:6379
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ volumes:
+ - .:/usr/ebicsbox
+ command: bin/start worker
+ networks:
+ - ebicsbox_network
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..ed154558
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,52 @@
+version: "3.7"
+networks:
+ ebicsbox_network:
+services:
+ web:
+ image: railslove/ebicsbox
+ restart: always
+ env_file:
+ - .env
+ - .web.env
+ expose:
+ - 5000
+ environment:
+ - PORT=5000
+ networks:
+ - ebicsbox_network
+ command: bin/start server
+ depends_on:
+ - proxy
+ worker:
+ image: railslove/ebicsbox
+ restart: always
+ env_file: .env
+ command: bin/start worker
+ networks:
+ - ebicsbox_network
+ proxy:
+ image: jwilder/nginx-proxy
+ restart: always
+ volumes:
+ - /var/run/docker.sock:/tmp/docker.sock:ro
+ - /tmp/certs:/etc/nginx/certs:ro
+ - /tmp/html:/usr/share/nginx/html:rw
+ - /tmp/vhost.d:/etc/nginx/vhost.d
+ labels:
+ - com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy
+ ports:
+ - 80:80
+ - 443:443
+ networks:
+ - ebicsbox_network
+ letsencrypt:
+ image: jrcs/letsencrypt-nginx-proxy-companion
+ volumes:
+ - /tmp/html:/usr/share/nginx/html:rw
+ - /tmp/vhost.d:/etc/nginx/vhost.d:rw
+ - /tmp/certs:/etc/nginx/certs:rw
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ depends_on:
+ - proxy
+ networks:
+ - ebicsbox_network
diff --git a/docs/docker.md b/docs/docker.md
new file mode 100644
index 00000000..81f67510
--- /dev/null
+++ b/docs/docker.md
@@ -0,0 +1,35 @@
+# ebicsbox 📦 on docker 🐳
+
+## What's the ebicsbox?
+
+To put it simple, the ebicsbox is a proxy for your bank's ebics interface to your application. Since the ebics standard is complex and not well readable, also a lot of security, handshaking and other things that are really hard, the ebicsbox provides you and your application with a handful of RESTful API endpoints to talk to your bank with simple JSON requests
+
+## How to setup
+
+- Make sure `docker` & `docker-compose` are setup and running.
+ This can be done via `(sudo) apt install docker-compose` which will automatically install docker if it's not already installed
+- Create a new folder (e.g. `/home/ebicsbox`) for you ebicsbox and place the files in it
+- If you decide to use the internal databases, create a subfolder `postgres` or similar. Make sure this path is in your backup-loop.
+- Rename the `.env.example` to `.env` and update the content
+ If you decide to use the internal database make sure to provide the path you created in the step before.
+- Rename the `.web.env.example` to `.web.env` and adjust the content - see the files for more comments on the configuration
+- Start the cluster with `docker-compose up` and check for errors once everything is started.
+ If you decide to use the internal DBs, use `docker-compose -f docker-compose.yml -f docker-compose.with_db.yml up`
+
+_Note:_ If you start the cluster for the first time, the worker and web will give out a lot of errors while the database is being setup. This should stop once the db is up and running.
+
+## Check if everything is running
+
+If you get `{"message":"Unauthorized access. Please provide a valid access token!"}` by running the following command, everything is working perfectly
+
+```bash
+ curl -XGET https://YOUR_VIRTUAL_HOST
+```
+
+You should be able to access the API-docs via `https://YOUR_VIRTUAL_HOST/docs`
+
+## Retrieving the admins access token
+
+```bash
+ (sudo) docker exec -it ebicsbox_db_1 psql -U EBICSBOX_USER_NAME -W -d ebicsbox -c 'select access_token from users where id = 1'
+```
diff --git a/docs/heroku.md b/docs/heroku.md
new file mode 100644
index 00000000..263ce527
--- /dev/null
+++ b/docs/heroku.md
@@ -0,0 +1,145 @@
+# What's heroku?
+
+[heroku](https://heroku.com) is a Platform as a Service Provider. Instead of hosting the ebicsbox on your own infrastructure it's possible to host the ebicsbox on heroku and benefit from the ease of deployment and the flexibility of scaling as needed.
+
+## Requirements
+
+- [Heroku](https://heroku.com) Account
+- [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)
+
+## Initial setup
+
+#### Prepare your CLI
+
+Prepare your heroku CLI by updating to beta and activating the manifest plugin:
+
+```bash
+> heroku update beta
+# heroku: Updating CLI from 7.19.4 to 7.19.4-beta.2a7fcad (beta)... done
+# heroku: Updating CLI... done
+# Updating completions... done
+
+> heroku plugins:install @heroku-cli/plugin-manifest
+# Installing plugin manifest... installed v0.0.5
+
+> heroku plugins:install heroku-open-dashboard
+# Installing plugin heroku-open-dashboard... installed v1.0.6
+```
+
+#### Create the app
+
+After doing so, setup the new app **from within the folder with the `heroku.yml`** using the manifest option
+
+```bash
+> heroku create DESIRED_APP_NAME --manifest --team=TEAM --region=eu
+# Reading heroku.yml manifest... done
+# Creating ⬢ ebicsbox... done, region is eu, stack is container
+# Adding heroku-postgresql... done
+# Adding heroku-redis... done
+```
+
+#### Set ENV
+
+If you have not created the app via manifest, make sure to the set the stack to container by running
+
+```bash
+heroku stack:set container -a DESIRED_APP_NAME
+```
+
+Afterward, make sure to set the required environment variables
+
+```bash
+> heroku config:set --app DESIRED_APP_NAME PASSPHRASE=`openssl rand -base64 32`
+# Setting PASSPHRASE and restarting ⬢ ebicsbox... done, v7
+# PASSPHRASE: o6Hsd6BtA8o+h/BdCg04u71KbWVsBdPpShy2jWCGN80=
+
+> heroku config:set --app DESIRED_APP_NAME APP_URL=URL_FOR_THE_APP
+# Setting APP_URL and restarting ⬢ ebicsbox... done, v8
+# APP_URL: https://ebicsbox.herokuapp.com/
+```
+
+#### Manage Resources
+
+Then open the apps overview
+
+```bash
+> heroku browse:resources --app DESIRED_APP_NAME
+```
+
+and you should see something like this, where you can adjust your databse and redis plan to your desire.
+
+
+
+#### Sentry
+
+If you'd like you could setup sentry as follows to keep track of errors and mishaps.
+
+In any case you need to enable meta-info for your dynos:
+
+```bash
+> heroku labs:enable runtime-dyno-metadata --app DESIRED_APP_NAME
+# Enabling runtime-dyno-metadata for ebicsbox... done
+```
+
+For self-hosted sentry-instances, set the required environment variable
+
+```bash
+> heroku config:set --app DESIRED_APP_NAME SENTRY_DSN=https://example.org/sentry/1337
+# Setting SENTRY_DSN and restarting ⬢ ebicsbox... done, v9
+# SENTRY_DSN: https://example.org/sentry/1337
+```
+
+or add the heroku plugin, which will create a Sentry account for you automatically set `SENTRY_DSN`
+
+```bash
+> heroku addons:add --app DESIRED_APP_NAME sentry
+# Creating sentry on ⬢ ebicsbox... free
+# Sentry has been provisioned
+#
+# Please visit https://docs.sentry.io/ for further instructions
+# Created sentry-animate-94502 as SENTRY_DSN
+```
+
+#### Deploy to heroku
+
+Then add the git handle and push the first iteration.
+
+```bash
+> heroku git:remote --remote heroku --app DESIRED_APP_NAME
+# set git remote heroku to https://git.heroku.com/ebicsbox.git
+
+> git push heroku master
+# Enumerating objects: 5758, done.
+# Counting objects: 100% (5758/5758), done.
+# Delta compression using up to 4 threads
+# Compressing objects: 100% (2597/2597), done.
+# Writing objects: 100% (5758/5758), 2.33 MiB | 463.00 KiB/s, done.
+# Total 5758 (delta 3330), reused 4953 (delta 2783)
+# remote: Compressing source files... done.
+# remote: Building source:
+# remote: === Fetching app code
+# remote:
+# remote: === Building worker (dockerfiles/Dockerfile.worker)
+# ...
+# ...
+# this might take a while
+# ...
+# ...
+# remote: latest: digest: sha256:cf7e91af1e458d1cd45504a20185cdf421c1d44a3d55c818b40106f4ccbcb317 size: 5125
+# remote:
+# remote: Verifying deploy... done.
+# remote: Running release command....
+# remote:
+# remote: migrated to:
+# remote: Waiting for release.... done.
+# To https://git.heroku.com/ebicsbox.git
+```
+
+Voilà, that should do the trick. Go and try reloading the page.
+
+Also, make sure you have at least one worker dyno startet as it seems that this is not the case by default. To do so run:
+
+```bash
+> heroku ps:scale web=1 worker=1 --app DESIRED_APP_NAME
+# Scaling dynos... done, now running worker at 1:Hobby, web at 1:Hobby
+```
diff --git a/docs/resources.png b/docs/resources.png
new file mode 100644
index 00000000..c4e306fe
Binary files /dev/null and b/docs/resources.png differ
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 00000000..07e27e70
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+# migrate db
+bin/migrate
+
+# Hand off to the CMD
+exec "$@"
diff --git a/heroku.yml b/heroku.yml
new file mode 100644
index 00000000..95e66509
--- /dev/null
+++ b/heroku.yml
@@ -0,0 +1,24 @@
+setup:
+ addons:
+ - plan: heroku-postgresql
+ as: DATABASE
+ - plan: heroku-redis
+ as: REDIS
+ config:
+ RACK_ENV: production
+ AUTH_SERVICE: static
+build:
+ docker:
+ web: Dockerfile
+ worker: Dockerfile
+ config:
+ RACK_ENV: production
+ AUTH_SERVICE: static
+ PORT: 5000
+release:
+ image: web
+ command:
+ - bin/migrate
+run:
+ web: bundle exec rackup -p $PORT
+ worker: bundle exec sidekiq -C ./config/sidekiq.yml -r ./config/sidekiq.rb
diff --git a/lib/checksum_generator.rb b/lib/checksum_generator.rb
new file mode 100644
index 00000000..bf75f2c9
--- /dev/null
+++ b/lib/checksum_generator.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ChecksumGenerator
+ class << self
+ def from_payload(payload)
+ payload = payload.flatten.compact.map(&:to_s).reject(&:empty?).join if payload.is_a?(Array)
+ Digest::SHA2.hexdigest(payload).to_s
+ end
+ end
+end
diff --git a/lib/checksum_updater.rb b/lib/checksum_updater.rb
new file mode 100644
index 00000000..80444dc1
--- /dev/null
+++ b/lib/checksum_updater.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require_relative "checksum_generator"
+require_relative "../box/models/statement"
+require_relative "../box/business_processes/import_statements"
+
+class ChecksumUpdater
+ attr_accessor :transaction, :remote_account
+
+ def initialize(transaction, remote_account)
+ self.transaction = transaction
+ self.remote_account = remote_account
+ end
+
+ def call
+ old_checksum = ChecksumGenerator.from_payload(old_checksum_payload)
+ new_checksum = ChecksumGenerator.from_payload(new_checksum_payload)
+
+ Box::Statement.find(sha: old_checksum)&.update(sha2: new_checksum)
+ end
+
+ private
+
+ def new_checksum_payload
+ Box::BusinessProcesses::ImportStatements.checksum_attributes(transaction, remote_account)
+ end
+
+ def old_checksum_payload
+ Box::BusinessProcesses::ImportStatements.payload_from_transaction_attributes(transaction, remote_account)
+ end
+end
diff --git a/lib/data_mapping/camt53/statement.rb b/lib/data_mapping/camt53/statement.rb
new file mode 100644
index 00000000..9b3b70b9
--- /dev/null
+++ b/lib/data_mapping/camt53/statement.rb
@@ -0,0 +1,39 @@
+module DataMapping
+ module Camt53
+ class Statement
+ attr_reader :raw_bank_statement
+
+ def initialize(raw_bank_statement)
+ @raw_bank_statement = raw_bank_statement
+ end
+
+ def blank?
+ raw_bank_statement.blank?
+ end
+
+ def account_identification
+ raw_bank_statement.account_identification
+ end
+
+ def closing_or_intermediary_balance
+ raw_bank_statement.closing_or_intermediary_balance
+ end
+
+ def sequence
+ raw_bank_statement.electronic_sequence_number
+ end
+
+ def opening_or_intermediary_balance
+ raw_bank_statement.closing_or_intermediary_balance
+ end
+
+ def source
+ raw_bank_statement.source
+ end
+
+ def transactions
+ raw_bank_statement.transactions
+ end
+ end
+ end
+end
diff --git a/lib/data_mapping/cmxl/statement.rb b/lib/data_mapping/cmxl/statement.rb
new file mode 100644
index 00000000..d39d773d
--- /dev/null
+++ b/lib/data_mapping/cmxl/statement.rb
@@ -0,0 +1,23 @@
+module DataMapping
+ module Cmxl
+ class Statement
+ attr_reader :raw_bank_statement
+
+ delegate :account_identification,
+ :blank?,
+ :closing_or_intermediary_balance,
+ :opening_or_intermediary_balance,
+ :source,
+ :transactions,
+ to: :raw_bank_statement
+
+ def initialize(raw_bank_statement)
+ @raw_bank_statement = raw_bank_statement
+ end
+
+ def sequence
+ raw_bank_statement.legal_sequence_number
+ end
+ end
+ end
+end
diff --git a/lib/data_mapping/statement_factory.rb b/lib/data_mapping/statement_factory.rb
new file mode 100644
index 00000000..d46d95ae
--- /dev/null
+++ b/lib/data_mapping/statement_factory.rb
@@ -0,0 +1,22 @@
+require_relative "../../lib/data_mapping/camt53/statement"
+require_relative "../../lib/data_mapping/cmxl/statement"
+
+module DataMapping
+ class StatementFactory
+ attr_reader :raw_bank_statement, :account
+
+ def initialize(raw_bank_statement, account)
+ @raw_bank_statement = raw_bank_statement
+ @account = account
+ end
+
+ def call
+ case account.statements_format
+ when "camt53"
+ DataMapping::Camt53::Statement.new(raw_bank_statement)
+ when "mt940"
+ DataMapping::Cmxl::Statement.new(raw_bank_statement)
+ end
+ end
+ end
+end
diff --git a/lib/pain.rb b/lib/pain.rb
new file mode 100644
index 00000000..2742f7d6
--- /dev/null
+++ b/lib/pain.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+require_relative "pain/credit_03"
+require_relative "pain/debit_02"
+
+module Pain
+ UnknownInput = Class.new(ArgumentError)
+ def self.from_xml(raw_xml)
+ doc = Nokogiri::XML(raw_xml).at_xpath("//xmlns:Document")
+ case doc.namespace.href
+ when "urn:iso:std:iso:20022:tech:xsd:pain.001.003.03"
+ Pain::Credit03.new(doc)
+ when "urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"
+ Pain::Debit02.new(doc)
+ else
+ raise UnknownInput, "Unknown xml file contents"
+ end
+ rescue Nokogiri::XML::XPath::SyntaxError => _ex
+ raise UnknownInput, "Invalid XML input"
+ end
+end
diff --git a/lib/pain/credit_03.rb b/lib/pain/credit_03.rb
new file mode 100644
index 00000000..321228f4
--- /dev/null
+++ b/lib/pain/credit_03.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+require "bigdecimal"
+
+module Pain
+ class Credit03
+ attr_accessor :doc
+
+ def initialize(doc)
+ self.doc = doc.at_xpath("/xmlns:Document/xmlns:CstmrCdtTrfInitn")
+ end
+
+ def to_h(_options = {})
+ {
+ id: get_content("./xmlns:GrpHdr/xmlns:MsgId"),
+ type: "credit",
+ created_at: get_content("./xmlns:GrpHdr/xmlns:CreDtTm"),
+ transactions_count: get_content("./xmlns:GrpHdr/xmlns:NbOfTxs").to_i,
+ total_amount: BigDecimal(get_content("./xmlns:GrpHdr/xmlns:CtrlSum")),
+ initiating_party: {
+ name: get_content("./xmlns:GrpHdr/xmlns:InitgPty/xmlns:Nm")
+ },
+ payments: doc.xpath("./xmlns:PmtInf").map { |payment| payment_info(payment) }
+ }
+ end
+
+ alias_method :to_hash, :to_h
+ alias_method :as_json, :to_h
+
+ protected
+
+ def get_content(xpath, root = doc)
+ root.at_xpath(xpath).try(:content)
+ end
+
+ def payment_info(payment)
+ {
+ id: get_content("./xmlns:PmtInfId", payment),
+ execution_date: get_content("./xmlns:ReqdExctnDt", payment),
+ account: get_content("./xmlns:Dbtr//xmlns:Nm", payment),
+ iban: get_content("./xmlns:DbtrAcct/xmlns:Id/xmlns:IBAN", payment),
+ bic: get_content("./xmlns:DbtrAgt/xmlns:FinInstnId/xmlns:BIC", payment),
+ transactions: payment.xpath("./xmlns:CdtTrfTxInf").map { |trx| transaction(trx) }
+ }
+ end
+
+ def transaction(transaction)
+ {
+ eref: get_content("./xmlns:PmtId/xmlns:EndToEndId", transaction),
+ name: get_content("./xmlns:Cdtr/xmlns:Nm", transaction),
+ amount: BigDecimal(get_content("./xmlns:Amt/xmlns:InstdAmt", transaction)),
+ iban: get_content("./xmlns:CdtrAcct/xmlns:Id/xmlns:IBAN", transaction),
+ bic: get_content("./xmlns:CdtrAgt/xmlns:FinInstnId/xmlns:BIC", transaction),
+ remittance_information: get_content("./xmlns:RmtInf/xmlns:Ustrd", transaction)
+ }
+ end
+ end
+end
diff --git a/lib/pain/debit_02.rb b/lib/pain/debit_02.rb
new file mode 100644
index 00000000..1993e3a1
--- /dev/null
+++ b/lib/pain/debit_02.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+module Pain
+ class Debit02
+ attr_accessor :doc
+
+ def initialize(doc)
+ self.doc = doc.at_xpath("/xmlns:Document/xmlns:CstmrDrctDbtInitn")
+ end
+
+ def to_h(_options = {})
+ {
+ id: get_content("./xmlns:GrpHdr/xmlns:MsgId"),
+ type: "direct_debit",
+ created_at: get_content("./xmlns:GrpHdr/xmlns:CreDtTm"),
+ transactions_count: get_content("./xmlns:GrpHdr/xmlns:NbOfTxs").to_i,
+ total_amount: BigDecimal(get_content("./xmlns:GrpHdr/xmlns:CtrlSum")),
+ initiating_party: {
+ name: get_content("./xmlns:GrpHdr/xmlns:InitgPty/xmlns:Nm")
+ },
+ payments: doc.xpath("./xmlns:PmtInf").map { |payment| payment_info(payment) }
+ }
+ end
+
+ alias_method :to_hash, :to_h
+ alias_method :as_json, :to_h
+
+ protected
+
+ def get_content(xpath, root = doc)
+ root.at_xpath(xpath).try(:content)
+ end
+
+ def payment_info(payment)
+ {
+ id: get_content("./xmlns:PmtInfId", payment),
+ collection_date: get_content("./xmlns:ReqdColltnDt", payment),
+ account: get_content("./xmlns:Cdtr//xmlns:Nm", payment),
+ iban: get_content("./xmlns:CdtrAcct/xmlns:Id/xmlns:IBAN", payment),
+ bic: get_content("./xmlns:CdtrAgt/xmlns:FinInstnId/xmlns:BIC", payment),
+ transactions: payment.xpath("./xmlns:DrctDbtTxInf").map { |trx| transaction(trx) }
+ }
+ end
+
+ def transaction(transaction)
+ mandate = transaction.at_xpath("./xmlns:DrctDbtTx/xmlns:MndtRltdInf")
+ {
+ eref: get_content("./xmlns:PmtId/xmlns:EndToEndId", transaction),
+ name: get_content("./xmlns:Dbtr/xmlns:Nm", transaction),
+ amount: BigDecimal(get_content("./xmlns:InstdAmt", transaction)),
+ iban: get_content("./xmlns:DbtrAcct/xmlns:Id/xmlns:IBAN", transaction),
+ bic: get_content("./xmlns:DbtrAgt/xmlns:FinInstnId/xmlns:BIC", transaction),
+ remittance_information: get_content("./xmlns:RmtInf/xmlns:Ustrd", transaction),
+ mandate: {
+ id: get_content("./xmlns:MndtId", mandate),
+ signed_on: get_content("./xmlns:DtOfSgntr", mandate)
+ }
+ }
+ end
+ end
+end
diff --git a/public/setup/index.html b/public/setup/index.html
new file mode 100644
index 00000000..164af46a
--- /dev/null
+++ b/public/setup/index.html
@@ -0,0 +1,82 @@
+
+
+
+
>>u&mn;if(_!==h>>>u&mn)break;_&&(l+=(1<i&&(c=c.removeBefore(r,u,a-l)),c&&h
a&&(a=c.size),o(u)||(c=c.map(function(e){return K(e)})),i.push(c)}return a>e.size&&(e=e.setSize(a)),Ie(e,t,i)}function $e(e){return es)return k();var e=i.next();return r||t===bn?e:t===_n?w(t,u-1,void 0,e):w(t,u-1,e.value[1],e)})},c}function dt(e,t,n){var r=Dt(e);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var a=0;return e.__iterate(function(e,i,s){return t.call(n,e,i,s)&&++a&&r(e,i,o)}),a},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var a=e.__iterator(xn,i),s=!0;return new x(function(){if(!s)return k();var e=a.next();if(e.done)return e;var i=e.value,u=i[0],c=i[1];return t.call(n,c,u,o)?r===xn?e:w(r,u,c,e):(s=!1,k())})},r}function mt(e,t,n,r){var i=Dt(e);return i.__iterateUncached=function(i,o){var a=this;if(o)return this.cacheResult().__iterate(i,o);var s=!0,u=0;return e.__iterate(function(e,o,c){if(!s||!(s=t.call(n,e,o,c)))return u++,i(e,r?o:u-1,a)}),u},i.__iteratorUncached=function(i,o){var a=this;if(o)return this.cacheResult().__iterator(i,o);var s=e.__iterator(xn,o),u=!0,c=0;return new x(function(){var e,o,l;do{if(e=s.next(),e.done)return r||i===bn?e:i===_n?w(i,c++,void 0,e):w(i,c++,e.value[1],e);var p=e.value;o=p[0],l=p[1],u&&(u=t.call(n,l,o,a))}while(u);return i===xn?e:w(i,o,l,e)})},i}function vt(e,t){var r=a(e),i=[e].concat(t).map(function(e){return o(e)?r&&(e=n(e)):e=r?L(e):q(Array.isArray(e)?e:[e]),e}).filter(function(e){return 0!==e.size});if(0===i.length)return e;if(1===i.length){var u=i[0];if(u===e||r&&a(u)||s(e)&&s(u))return u}var c=new I(i);return r?c=c.toKeyedSeq():s(e)||(c=c.toSetSeq()),c=c.flatten(!0),c.size=i.reduce(function(e,t){if(void 0!==e){var n=t.size;if(void 0!==n)return e+n}},0),c}function gt(e,t,n){var r=Dt(e);return r.__iterateUncached=function(r,i){function a(e,c){var l=this;e.__iterate(function(e,i){return(!t||c=Vn)return Me(e,f,c,s,d);if(l&&!d&&2===f.length&&Se(f[1^p]))return f[1^p];if(l&&d&&1===f.length&&Se(d))return d;var m=e&&e===this.ownerID,v=l?d?c:c^u:c|u,g=l?d?Ne(f,p,d,m):Be(f,p,m):Fe(f,p,d,m);return m?(this.bitmap=v,this.nodes=g,this):new de(e,v,g)},me.prototype.get=function(e,t,n,r){void 0===t&&(t=oe(n));var i=(0===e?t:t>>>e)&mn,o=this.nodes[i];return o?o.get(e+hn,t,n,r):r},me.prototype.update=function(e,t,n,r,i,o,a){void 0===n&&(n=oe(r));var s=(0===t?n:n>>>t)&mn,u=i===vn,c=this.nodes,l=c[s];if(u&&!l)return this;var p=Ee(l,e,t+hn,n,r,i,o,a);if(p===l)return this;var f=this.count;if(l){if(!p&&--f5e3)return e.textContent;return function(e){for(var n,r,i,o,a,s=e.textContent,u=0,c=s[0],l=1,p=e.innerHTML="",f=0;r=n,n=f<7&&"\\"==n?1:l;){if(l=c,c=s[++u],o=p.length>1,!l||f>8&&"\n"==l||[/\S/.test(l),1,1,!/[$\w]/.test(l),("/"==n||"\n"==n)&&o,'"'==n&&o,"'"==n&&o,s[u-4]+r+n=="--\x3e",r+n=="*/"][f])for(p&&(e.appendChild(a=t.createElement("span")).setAttribute("style",["color: #555; font-weight: bold;","","","color: #555;",""][f?f<3?2:f>6?4:f>3?3:+/^(a(bstract|lias|nd|rguments|rray|s(m|sert)?|uto)|b(ase|egin|ool(ean)?|reak|yte)|c(ase|atch|har|hecked|lass|lone|ompl|onst|ontinue)|de(bugger|cimal|clare|f(ault|er)?|init|l(egate|ete)?)|do|double|e(cho|ls?if|lse(if)?|nd|nsure|num|vent|x(cept|ec|p(licit|ort)|te(nds|nsion|rn)))|f(allthrough|alse|inal(ly)?|ixed|loat|or(each)?|riend|rom|unc(tion)?)|global|goto|guard|i(f|mp(lements|licit|ort)|n(it|clude(_once)?|line|out|stanceof|t(erface|ernal)?)?|s)|l(ambda|et|ock|ong)|m(icrolight|odule|utable)|NaN|n(amespace|ative|ext|ew|il|ot|ull)|o(bject|perator|r|ut|verride)|p(ackage|arams|rivate|rotected|rotocol|ublic)|r(aise|e(adonly|do|f|gister|peat|quire(_once)?|scue|strict|try|turn))|s(byte|ealed|elf|hort|igned|izeof|tatic|tring|truct|ubscript|uper|ynchronized|witch)|t(emplate|hen|his|hrows?|ransient|rue|ry|ype(alias|def|id|name|of))|u(n(checked|def(ined)?|ion|less|signed|til)|se|sing)|v(ar|irtual|oid|olatile)|w(char_t|hen|here|hile|ith)|xor|yield)$/.test(p):0]),a.appendChild(t.createTextNode(p))),i=f&&f<7?f:i,p="",f=11;![1,/[\/{}[(\-+*=<>:;|\\.,?!&@~]/.test(l),/[\])]/.test(l),/[$\w]/.test(l),"/"==l&&i<2&&"<"!=n,'"'==l,"'"==l,l+c+s[u+1]+s[u+2]=="\x3c!--",l+c=="/*",l+c=="//","#"==l][--f];);p+=l}}(e)}function x(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"key",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:F.default.Map();if(!F.default.Map.isMap(e)||!e.size)return F.default.List();if(Array.isArray(t)||(t=[t]),t.length<1)return e.merge(n);var r=F.default.List(),i=t[0],o=!0,a=!1,s=void 0;try{for(var u,c=(0,M.default)(e.entries());!(o=(u=c.next()).done);o=!0){var l=u.value,p=(0,A.default)(l,2),f=p[0],h=p[1],d=x(h,t.slice(1),n.set(i,f));r=F.default.List.isList(d)?r.concat(d):r.push(d)}}catch(e){a=!0,s=e}finally{try{!o&&c.return&&c.return()}finally{if(a)throw s}}return r}function w(e){return(0,z.default)((0,L.default)(e))}function k(e){return w(e.replace(/\.[^.\/]*$/,""))}Object.defineProperty(t,"__esModule",{value:!0}),t.shallowEqualKeys=t.buildFormData=t.sorters=t.btoa=t.parseSearch=t.getSampleSchema=t.validateParam=t.validateString=t.validateBoolean=t.validateFile=t.validateInteger=t.validateNumber=t.propChecker=t.errorLog=t.memoize=t.isImmutable=void 0;var E=n(42),S=r(E),C=n(16),A=r(C),D=n(89),M=r(D),O=n(34),T=r(O),P=n(52),I=r(P),j=n(43),R=r(j);t.isJSONObject=i,t.objectify=o,t.arrayify=a,t.fromJSOrdered=s,t.bindToState=u,t.normalizeArray=c,t.isFn=l,t.isObject=p,t.isFunc=f,t.isArray=h,t.objMap=d,t.objReduce=m,t.systemThunkMiddleware=v,t.defaultStatusCode=g,t.getList=y,t.formatXml=_,t.highlight=b,t.mapToList=x,t.pascalCase=w,t.pascalCaseFilename=k;var N=n(8),F=r(N),B=n(879),L=r(B),q=n(405),z=r(q),U=n(403),W=r(U),V=n(397),K=r(V),H=n(894),J=r(H),G=n(112),X=r(G),Y=n(166),$=n(51),Z=r($),Q="default",ee=t.isImmutable=function(e){return F.default.Iterable.isIterable(e)},te=(t.memoize=W.default,t.errorLog=function(e){return function(){return function(t){return function(n){try{t(n)}catch(t){e().errActions.newThrownErr(t,n)}}}}},t.propChecker=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:[];return(0,I.default)(e).length!==(0,I.default)(t).length||((0,J.default)(e,function(e,n){if(r.includes(n))return!1;var i=t[n];return F.default.Iterable.isIterable(e)?!F.default.is(e,i):("object"!==(void 0===e?"undefined":(0,R.default)(e))||"object"!==(void 0===i?"undefined":(0,R.default)(i)))&&e!==i})||n.some(function(n){return!(0,X.default)(e[n],t[n])}))},t.validateNumber=function(e){if(!/^-?\d+(\.?\d+)?$/.test(e))return"Value must be a number"}),ne=t.validateInteger=function(e){if(!/^-?\d+$/.test(e))return"Value must be an integer"},re=t.validateFile=function(e){if(e&&!(e instanceof Z.default.File))return"Value must be a file"},ie=t.validateBoolean=function(e){if("true"!==e&&"false"!==e&&!0!==e&&!1!==e)return"Value must be a boolean"},oe=t.validateString=function(e){if(e&&"string"!=typeof e)return"Value must be a string"};t.validateParam=function(e,t){var n=[],r=t&&"body"===e.get("in")?e.get("value_xml"):e.get("value"),i=e.get("required"),o=e.get("type");if(o&&(i||r)){var a="string"===o&&r&&!oe(r),s="array"===o&&Array.isArray(r)&&r.length,u="array"===o&&F.default.List.isList(r)&&r.count(),c="file"===o&&r instanceof Z.default.File,l="boolean"===o&&!ie(r),p="number"===o&&!te(r),f="integer"===o&&!ne(r);if(i&&!(a||s||u||c||l||p||f))return n.push("Required field is not provided"),n;if("string"===o){var h=oe(r);if(!h)return n;n.push(h)}else if("boolean"===o){var d=ie(r);if(!d)return n;n.push(d)}else if("number"===o){var m=te(r);if(!m)return n;n.push(m)}else if("integer"===o){var v=ne(r);if(!v)return n;n.push(v)}else if("array"===o){var g=void 0;if(!r.count())return n;g=e.getIn(["items","type"]),r.forEach(function(e,t){var r=void 0;"number"===g?r=te(e):"integer"===g?r=ne(e):"string"===g&&(r=oe(e)),r&&n.push({index:t,error:r})})}else if("file"===o){var y=re(r);if(!y)return n;n.push(y)}}return n},t.getSampleSchema=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};if(/xml/.test(t)){if(!e.xml||!e.xml.name){if(e.xml=e.xml||{},!e.$$ref)return e.type||e.items||e.properties||e.additionalProperties?'\n\x3c!-- XML example cannot be generated --\x3e':null;var r=e.$$ref.match(/\S*\/(\S+)$/);e.xml.name=r[1]}return(0,Y.memoizedCreateXMLExample)(e,n)}return(0,S.default)((0,Y.memoizedSampleFromSchema)(e,n),null,2)},t.parseSearch=function(){var e={},t=window.location.search;if(""!=t){var n=t.substr(1).split("&");for(var r in n)r=n[r].split("="),e[decodeURIComponent(r[0])]=decodeURIComponent(r[1])}return e},t.btoa=function(t){var n=void 0;return n=t instanceof e?t:new e(t.toString(),"utf-8"),n.toString("base64")},t.sorters={operationsSorter:{alpha:function(e,t){return e.get("path").localeCompare(t.get("path"))},method:function(e,t){return e.get("method").localeCompare(t.get("method"))}},tagsSorter:{alpha:function(e,t){return e.localeCompare(t)}}},t.buildFormData=function(e){var t=[];for(var n in e){var r=e[n];void 0!==r&&""!==r&&t.push([n,"=",encodeURIComponent(r).replace(/%20/g,"+")].join(""))}return t.join("&")},t.shallowEqualKeys=function(e,t,n){return!!(0,K.default)(n,function(n){return(0,X.default)(e[n],t[n])})}}).call(t,n(45).Buffer)},function(e,t,n){"use strict";function r(e){if(null===e||void 0===e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}/*
+object-assign
+(c) Sindre Sorhus
+@license MIT
+*/
+var i=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map(function(e){return t[e]}).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach(function(e){r[e]=e}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,t){for(var n,s,u=r(e),c=1;c5?c-5:0),p=5;p5?c-5:0),p=5;ps&&(n=s-u),c=n;c>=0;c--){for(var p=!0,f=0;fi&&(r=i):r=i;var o=t.length;if(o%2!=0)throw new TypeError("Invalid hex string");r>o/2&&(r=o/2);for(var a=0;a