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. +![CI](https://github.com/railslove/ebicsbox/actions/workflows/pr_action.yml/badge.svg?branch=main) + +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. + +![](./resources.png) + +#### 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 @@ + + + + + + + + + Setup Ebics box + + + + + + +
+

Setup your box

+
+
+ + + +
+
+ + + \ No newline at end of file diff --git a/public/swagger/doc/swagger-v1.json b/public/swagger/doc/swagger-v1.json new file mode 100644 index 00000000..05de267e --- /dev/null +++ b/public/swagger/doc/swagger-v1.json @@ -0,0 +1,1513 @@ +{ + "swagger": "2.0", + "info": { + "title": "EbicsBox", + "description": "A modern API for bank accounts. Fully automatize processing of incoming and outgoing money\ntransactions. It enables high-level access to some EBCIS features and wrapps them with further\nfunctinality.\n\n\n## Clarification of terms\n\n### EREF\n\nThe most important building block of the EBICS::BOX is the EREF aka \"End to End ID\" or\n\"End to End Reference\". It is a universal identifier that will be used to recognize transactions\nthroughout their whole lifecycle. The maximum length is 35 characters.\n\n### Matchmaking\n\nEvery time a new \"outgoing\" transaction is created (debit or credit) the EREF will be stored on\nthe internal watchlist, whenever we're seeing these IDs in new transactions you'll get notified\nvia Webhooks. The most used use case will be to identify chargebacks or detect that the money\nwas actullay transfered from your bank account.\n\n### Media Types\n\nAll actions require and return JSON formatted data. Timestamps are always formatted using\nISO 8601. All data is UTF-8 encoded.\n\n Content-Type: application/json\n\n### Errors\n\nErrorsDue to its REST nature, the API returns proper http error codes. Usually status codes in\nthe 2xx range indicate a successful operation, 4xx indicates an error resulting from the\nprovided attributes. And errors in the 5xx range indicate a problem in the EBICS::BOX. The JSON\nobject returned looks like the following:\n\n {\n \"message\": \"Human readable description of the error\",\n \"errors\": {\n \"\": [ \"some error\", \"another error\" ]\n }\n }\n\n### Versioning\n\nIf not specified otherwise, the API will always use the most recent version available. In order\nto use a specific version, clients need to request it via header:\n```Accept: application/vnd.ebicsbox.v1+json```.\n\nPlease note that we expect applications to be flexible enought to accept additional fields\nwithout a major version change. Breaking changes, like changed behaviour and removal or renaming\nof fields will always result in a version number bump.\n\n### Prerequisites\n\nTo use every feature that is offered by the EBICS::BOX you should make sure that your bank\nsupports and offers the respective order types.\n\n* Transaction Import - ```STA```\n* Usage protocols - ```HAC```\n* Credits - ```CCT```\n* Debits - ```CD1```, ```CDD``` or ```B2B```\n\nFurthermore to process direct debits you'll have to obtain a Creditor Identification Number from\nthe [Bundesbank](http://www.bundesbank.de/Navigation/DE/Aufgaben/Unbarer_Zahlungsverkehr/SEPA/Glaeubiger_Identifikationsnummer/glaeubiger_identifikationsnummer.html)\nand sign some additional contracts with your bank.\n", + "termsOfService": "https://localhost/tos.html", + "contact": { + "name": "Railslove", + "email": "contact@ebicsbox.com", + "url": "http://www.ebicsbox.com/" + }, + "version": "0.1" + }, + "basePath": "/", + "schemes": [ + "https", + "http" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/": { + "get": { + "tags": [ + "Service" + ], + "summary": "Renders homepage with most relevant information", + "description": "Renders homepage with most relevant information", + "responses": { + "default": { + "description": "Unexpected error" + } + } + } + }, + "/accounts": { + "get": { + "tags": [ + "Accessible resources" + ], + "summary": "Returns a list of all accessible accounts", + "description": "Returns a list of all accessible accounts", + "operationId": "accounts", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "include", + "in": "query", + "description": "Additional data to include. Can be one of: ebics_user", + "type": "object" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::Account" + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/events": { + "get": { + "tags": [ + "Accessible resources" + ], + "summary": "List of all events", + "description": "Paginated list of all events which occured on an organization. These are not account\nspecific. Each event will trigger a webhook delivery as long as a webhook endpoint\nis specified for an account. To get more data on webhook deliveries, please check an\nevents details by following the self source in its _links section.\n", + "operationId": "events", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "page", + "in": "query", + "description": "page through the results", + "type": "integer", + "default": 1 + }, + { + "name": "per_page", + "in": "query", + "description": "how many results per page", + "type": "integer", + "default": 10 + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::Event" + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/events/{id}": { + "get": { + "tags": [ + "Accessible resources" + ], + "summary": "Details for an event", + "description": "Get details on every triggered event. In case of a webhook delivery, all attempts\nare listed. For each attempt we store data on its response and errors if any are\nencountered. After 10 attempts, the system will stop to any retries.\n", + "operationId": "event_details", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::Event" + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/accounts": { + "get": { + "tags": [ + "Management" + ], + "summary": "Retrieve a list of all onboarded accounts", + "description": "Retrieve a list of all onboarded accounts", + "operationId": "management_accounts", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::ManagementAccount" + } + } + }, + "default": { + "description": "Unexpected error" + } + } + }, + "post": { + "tags": [ + "Management" + ], + "summary": "Create a new account", + "description": "Create a new account", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "name", + "iban", + "bic" + ], + "type": "object", + "properties": { + "name": { + "description": "Internal description of account", + "type": "string" + }, + "iban": { + "description": "IBAN", + "type": "string" + }, + "bic": { + "description": "BIC", + "type": "string" + }, + "bankname": { + "description": "Name of bank (for internal purposes)", + "type": "string" + }, + "creditor_identifier": { + "description": "creditor_identifier", + "type": "string" + }, + "callback_url": { + "description": "callback_url", + "type": "string" + }, + "host": { + "description": "host", + "type": "string" + }, + "partner": { + "description": "partner", + "type": "string" + }, + "url": { + "description": "url", + "type": "string" + }, + "mode": { + "description": "mode", + "type": "string" + } + } + } + } + ], + "responses": { + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/accounts/{account_id}/ebics_users": { + "get": { + "tags": [ + "Management" + ], + "summary": "Retrieve a list of all ebics_users for given account", + "description": "Retrieve a list of all ebics_users for given account", + "operationId": "management_account_ebics_users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account_id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::EbicsUser" + } + } + }, + "default": { + "description": "Unexpected error" + } + } + }, + "post": { + "tags": [ + "Management" + ], + "summary": "Add a ebics_user to given account", + "description": "Add a ebics_user to given account", + "operationId": "management_account_ebics_user_create", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account_id", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "user_id", + "ebics_user" + ], + "type": "object", + "properties": { + "user_id": { + "description": "Internal user identifier to associate the ebics_user with", + "type": "integer" + }, + "ebics_user": { + "description": "EBICS user to represent", + "type": "string" + } + } + } + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/accounts/{account_id}/ebics_users/{id}/ini_letter": { + "get": { + "tags": [ + "Management" + ], + "summary": "Fetch an account's INI letter", + "description": "Fetch an account's INI letter", + "operationId": "management_account_ini_letter", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account_id", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/accounts/{id}": { + "get": { + "tags": [ + "Management" + ], + "summary": "Retrieve a single account by its IBAN", + "description": "Retrieve a single account by its IBAN", + "operationId": "management_account", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::ManagementAccount" + } + }, + "default": { + "description": "Unexpected error" + } + } + }, + "put": { + "tags": [ + "Management" + ], + "summary": "Update an existing account", + "description": "Update an existing account", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "type": "object", + "properties": { + "name": { + "description": "Internal description of account", + "type": "string" + }, + "iban": { + "description": "IBAN", + "type": "string" + }, + "bic": { + "description": "BIC", + "type": "string" + }, + "bankname": { + "description": "Name of bank (for internal purposes)", + "type": "string" + }, + "creditor_identifier": { + "description": "creditor_identifier", + "type": "string" + }, + "callback_url": { + "description": "callback_url", + "type": "string" + }, + "host": { + "description": "host", + "type": "string" + }, + "partner": { + "description": "partner", + "type": "string" + }, + "url": { + "description": "url", + "type": "string" + }, + "mode": { + "description": "mode", + "type": "string" + } + } + } + } + ], + "responses": { + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/accounts/{id}/submit": { + "put": { + "tags": [ + "Management" + ], + "summary": "Submit a newly created account?", + "description": "Submit a newly created account?", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/users": { + "get": { + "tags": [ + "Management" + ], + "summary": "Retrieve a list of all users", + "description": "Retrieve a list of all users", + "operationId": "management_users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::User" + } + } + }, + "default": { + "description": "Unexpected error" + } + } + }, + "post": { + "tags": [ + "Management" + ], + "summary": "Create a new user instance", + "description": "Create a new user instance", + "operationId": "management_user_create", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "description": "The user's display name", + "type": "string" + }, + "token": { + "description": "Set a custom access token", + "type": "string" + } + } + } + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/management/users/{id}": { + "get": { + "tags": [ + "Management" + ], + "summary": "Retrieve a single user by its identifier", + "description": "Retrieve a single user by its identifier", + "operationId": "management_user", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::User" + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/organizations": { + "post": { + "tags": [ + "Registration" + ], + "summary": "Create a new organization", + "description": "Create a new organization", + "operationId": "registration_organization", + "parameters": [ + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "name", + "user" + ], + "type": "object", + "properties": { + "name": { + "description": "The organization's display name", + "type": "string" + }, + "user": { + "type": "object", + "properties": { + "name": { + "description": "The user's display name", + "type": "string" + }, + "access_token": { + "description": "Set a custom access token", + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "webhook_token": { + "description": "Token to sign organization's webhook payloads", + "type": "string" + } + } + } + } + ], + "responses": { + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}": { + "get": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Returns detaild information about a single account", + "description": "Returns detaild information about a single account", + "operationId": "accounts_show", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "200": { + "description": "Successful result of the operation", + "schema": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::Account" + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}/credits": { + "post": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Credit a customer's bank account", + "description": "Creating a credit by parameter should be the preferred way for low-volume transactions\nesp. for use cases where the PAIN XML isn't generated before.\n\nOnce validated, transactions are transmitted asynchronously to the banking system. Errors\nthat happen eventually are delivered via Webhooks.\n", + "operationId": "account_credit", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "name", + "bic", + "iban", + "amount", + "eref" + ], + "type": "object", + "properties": { + "name": { + "description": "the customers name", + "type": "string" + }, + "bic": { + "description": "the customers bic", + "type": "string" + }, + "iban": { + "description": "the customers iban", + "type": "string" + }, + "amount": { + "description": "amount to credit (charged in cents)", + "type": "integer" + }, + "eref": { + "description": "end to end id", + "type": "string" + }, + "remittance_information": { + "description": "description of the transaction (max. 140 char)", + "type": "string" + }, + "requested_date": { + "description": "requested execution date", + "type": "integer" + }, + "service_level": { + "description": "requested execution date", + "default": "SEPA", + "type": "string" + } + } + } + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}/debits": { + "post": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Debit a customer's bank account", + "description": "Creating a debit by parameter should be the preferred way for low-volume transactions esp. for use\ncases where the PAIN XML isn't generated before. Transactions can be transmitted either as ```CD1```\nor ```CDD``` depending on the order types your bank is offering you, the ```order_type``` parameter\nlets you choose among them.\n\nsequence_type\n\n* OOFF - one-off debit\n* FRST - first debit\n* RCUR - recurring debit\n* FNAL - final debit\n\nOnce validated, transactions are transmitted asynchronously to the banking system.\nErrors that happen eventually are delivered via Webhooks.\n", + "operationId": "accounts_debit", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "description": "the content of the request", + "schema": { + "required": [ + "name", + "bic", + "iban", + "amount", + "eref", + "mandate_id", + "mandate_signature_date" + ], + "type": "object", + "properties": { + "name": { + "description": "the customers name", + "type": "string" + }, + "bic": { + "description": "the customers bic", + "type": "string" + }, + "iban": { + "description": "the customers iban", + "type": "string" + }, + "amount": { + "description": "amount to debit (positive, charged in cents)", + "type": "integer" + }, + "eref": { + "description": "end to end id", + "type": "string" + }, + "mandate_id": { + "description": "ID of the SEPA mandate (max. 35 char)", + "type": "string" + }, + "mandate_signature_date": { + "description": "when the mandate was signed by the customer", + "type": "integer" + }, + "instrument": { + "default": "COR1", + "type": "string" + }, + "sequence_type": { + "default": "FRST", + "type": "string" + }, + "remittance_information": { + "description": "description of the transaction (max. 140 char)", + "type": "string" + }, + "instruction": { + "description": "instruction identification, will not be submitted to the debtor", + "type": "string" + }, + "requested_date": { + "description": "requested execution date", + "type": "integer" + } + } + } + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}/import/statements": { + "get": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Manually import statements for a given timeframe", + "description": "Use this endpoint to manually import statements. This might be useful if another system fetched data via STA and you now need to get this data again.", + "operationId": "accounts_import_statements", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "from", + "in": "query", + "description": "Date from which on to filter the results", + "type": "date" + }, + { + "name": "to", + "in": "query", + "description": "Date to which filter results", + "type": "date" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}/statements": { + "get": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Retrieve all account statements", + "description": "Transactions are imported on a daily basis and stored so they can be easily retrieved\nand searched for a timeframe that exceeds the usual timeframe your bank will hold them\non record for you. Besides pulling plain lists it is also possible to filter by eref or\nremittance_infomation.\n", + "operationId": "accounts_statements", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "transaction_id", + "in": "query", + "description": "filter all statements by a specific transaction id", + "type": "integer" + }, + { + "name": "page", + "in": "query", + "description": "page through the results", + "type": "integer", + "default": 1 + }, + { + "name": "per_page", + "in": "query", + "description": "how many results per page", + "type": "integer", + "default": 10 + }, + { + "name": "from", + "in": "query", + "description": "Date from which on to filter the results", + "type": "date" + }, + { + "name": "to", + "in": "query", + "description": "Date to which filter results", + "type": "date" + }, + { + "name": "type", + "in": "query", + "description": "Type of statement", + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/{account}/transactions": { + "get": { + "tags": [ + "Account specific endpoints" + ], + "summary": "Retrieve all executed orders", + "description": "Retrieve all executed orders", + "operationId": "accounts_transactions", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "OAuth 2 Bearer token", + "required": false, + "type": "string" + }, + { + "name": "account", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "page", + "in": "query", + "description": "page through the results", + "type": "integer", + "default": 1 + }, + { + "name": "per_page", + "in": "query", + "description": "how many results per page", + "type": "integer", + "default": 10 + } + ], + "responses": { + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Not authorized to access this resource" + }, + "404": { + "description": "No account with given IBAN found" + }, + "412": { + "description": "EBICS account credentials not yet activated" + }, + "default": { + "description": "Unexpected error" + } + } + } + } + }, + "definitions": { + "Epics::Box::Entities::Account": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name for given bank account" + }, + "iban": { + "type": "string", + "description": "Unique bank account IBAN" + }, + "bic": { + "type": "string", + "description": "Bank branch's unique BIC" + }, + "bankname": { + "type": "string", + "description": "Name of bank account's hosting bank" + }, + "creditor_identifier": { + "type": "string", + "description": "Creditor identifier used for direct debits" + }, + "balance_date": { + "type": "date", + "description": "Date of balance" + }, + "balance_in_cents": { + "type": "integer", + "description": "Account balance" + }, + "test_mode": { + "type": "boolean", + "description": "Whether this is a test account" + }, + "ebics_user": { + "type": "string" + }, + "_links": { + "type": "object", + "properties": { + + }, + "description": "Links to resources" + } + } + }, + "Epics::Box::Entities::Event": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "account": { + "type": "string", + "description": "Display name for given bank account" + }, + "type": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "triggered_at": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "webhook_status": { + "type": "string" + }, + "webhook_retries": { + "type": "string" + }, + "webhook_deliveries": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::WebhookDelivery" + }, + "_links": { + "type": "object", + "properties": { + + }, + "description": "Links to resources" + } + } + }, + "Epics::Box::Entities::ManagementAccount": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name for given bank account" + }, + "iban": { + "type": "string", + "description": "Unique bank account IBAN" + }, + "bic": { + "type": "string", + "description": "Bank branch's unique BIC" + }, + "bankname": { + "type": "string", + "description": "Name of bank account's hosting bank" + }, + "creditor_identifier": { + "type": "string", + "description": "Creditor identifier used for direct debits" + }, + "callback_url": { + "type": "string", + "description": "URL where webhooks are sent at" + }, + "url": { + "type": "string", + "description": "Bank's EBICS server URL" + }, + "host": { + "type": "string", + "description": "EBICS Host identifier" + }, + "partner": { + "type": "string", + "description": "EBICS partner identifier" + }, + "statements_format": { + "type": "string", + "description": "Fetching method for statements (either 'mt940' or 'camt53')" + }, + "test_mode": { + "type": "boolean", + "description": "Whether this is a test account" + }, + "ebics_users": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::EbicsUser" + }, + "_links": { + "type": "object", + "properties": { + + }, + "description": "Links to resources" + } + } + }, + "Epics::Box::Entities::EbicsUser": { + "type": "object", + "properties": { + "account": { + "type": "string", + "description": "Display name for given bank account" + }, + "id": { + "type": "integer", + "description": "Internal id" + }, + "user_id": { + "type": "string", + "description": "Associated user id" + }, + "ebics_user": { + "type": "string", + "description": "EBICS user identifier" + }, + "signature_class": { + "type": "string", + "description": "EBICS signature class" + }, + "state": { + "type": "string", + "description": "Current ebics_user state" + }, + "submitted_at": { + "type": "string", + "description": "Date and time when EBICS keys were submitted to bank server" + }, + "activated_at": { + "type": "string", + "format": "date-time", + "description": "Date and time when EBICS credentials have been activated" + }, + "_links": { + "type": "object", + "properties": { + + }, + "description": "Links to resources" + } + } + }, + "Epics::Box::Entities::User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Internal user id" + }, + "name": { + "type": "string", + "description": "Display name for given bank account" + }, + "access_token": { + "type": "string", + "description": "The user's access token" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Date and time when user was created" + }, + "ebics_users": { + "type": "object", + "$ref": "#/definitions/Epics::Box::Entities::EbicsUser" + }, + "_links": { + "type": "object", + "properties": { + + }, + "description": "Links to resources" + } + } + }, + "Epics::Box::Entities::WebhookDelivery": { + "type": "object", + "properties": { + "delivered_at": { + "type": "string" + }, + "response_body": { + "type": "string" + }, + "reponse_headers": { + "type": "string" + }, + "response_status": { + "type": "string" + }, + "response_time": { + "type": "string" + } + } + } + } +} diff --git a/public/swagger/favicon-16x16.png b/public/swagger/favicon-16x16.png new file mode 100755 index 00000000..0f7e13b0 Binary files /dev/null and b/public/swagger/favicon-16x16.png differ diff --git a/public/swagger/favicon-32x32.png b/public/swagger/favicon-32x32.png new file mode 100755 index 00000000..b0a3352f Binary files /dev/null and b/public/swagger/favicon-32x32.png differ diff --git a/public/swagger/index.html b/public/swagger/index.html new file mode 100755 index 00000000..8f08271b --- /dev/null +++ b/public/swagger/index.html @@ -0,0 +1,100 @@ + + + + + + Swagger UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/public/swagger/oauth2-redirect.html b/public/swagger/oauth2-redirect.html new file mode 100755 index 00000000..a7eb162d --- /dev/null +++ b/public/swagger/oauth2-redirect.html @@ -0,0 +1,57 @@ + + + + + + diff --git a/public/swagger/swagger-ui-bundle.js b/public/swagger/swagger-ui-bundle.js new file mode 100755 index 00000000..4b5a575c --- /dev/null +++ b/public/swagger/swagger-ui-bundle.js @@ -0,0 +1,81 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SwaggerUIBundle=t():e.SwaggerUIBundle=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return e[r].call(i.exports,i,i.exports,t),i.l=!0,i.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="/dist",t(t.s=1250)}([function(e,t,n){"use strict";e.exports=n(85)},function(e,t,n){e.exports=n(921)()},function(e,t,n){"use strict";t.__esModule=!0,t.default=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}},function(e,t,n){"use strict";t.__esModule=!0;var r=n(322),i=function(e){return e&&e.__esModule?e:{default:e}}(r);t.default=function(){function e(e,t){for(var n=0;n>>0;if(""+n!==t||4294967295===n)return NaN;t=n}return t<0?d(e)+t:t}function v(){return!0}function g(e,t,n){return(0===e||void 0!==n&&e<=-n)&&(void 0===t||void 0!==n&&t>=n)}function y(e,t){return b(e,t,0)}function _(e,t){return b(e,t,t)}function b(e,t,n){return void 0===e?n:e<0?Math.max(0,t+e):void 0===t?e:Math.min(t,e)}function x(e){this.next=e}function w(e,t,n,r){var i=0===e?t:1===e?n:[t,n];return r?r.value=i:r={value:i,done:!1},r}function k(){return{value:void 0,done:!0}}function E(e){return!!A(e)}function S(e){return e&&"function"==typeof e.next}function C(e){var t=A(e);return t&&t.call(e)}function A(e){var t=e&&(wn&&e[wn]||e[kn]);if("function"==typeof t)return t}function D(e){return e&&"number"==typeof e.length}function M(e){return null===e||void 0===e?B():o(e)?e.toSeq():z(e)}function O(e){return null===e||void 0===e?B().toKeyedSeq():o(e)?a(e)?e.toSeq():e.fromEntrySeq():L(e)}function T(e){return null===e||void 0===e?B():o(e)?a(e)?e.entrySeq():e.toIndexedSeq():q(e)}function P(e){return(null===e||void 0===e?B():o(e)?a(e)?e.entrySeq():e:q(e)).toSetSeq()}function I(e){this._array=e,this.size=e.length}function j(e){var t=Object.keys(e);this._object=e,this._keys=t,this.size=t.length}function R(e){this._iterable=e,this.size=e.length||e.size}function N(e){this._iterator=e,this._iteratorCache=[]}function F(e){return!(!e||!e[Sn])}function B(){return Cn||(Cn=new I([]))}function L(e){var t=Array.isArray(e)?new I(e).fromEntrySeq():S(e)?new N(e).fromEntrySeq():E(e)?new R(e).fromEntrySeq():"object"==typeof e?new j(e):void 0;if(!t)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+e);return t}function q(e){var t=U(e);if(!t)throw new TypeError("Expected Array or iterable object of values: "+e);return t}function z(e){var t=U(e)||"object"==typeof e&&new j(e);if(!t)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+e);return t}function U(e){return D(e)?new I(e):S(e)?new N(e):E(e)?new R(e):void 0}function W(e,t,n,r){var i=e._cache;if(i){for(var o=i.length-1,a=0;a<=o;a++){var s=i[n?o-a:a];if(!1===t(s[1],r?s[0]:a,e))return a+1}return a}return e.__iterateUncached(t,n)}function V(e,t,n,r){var i=e._cache;if(i){var o=i.length-1,a=0;return new x(function(){var e=i[n?o-a:a];return a++>o?k():w(t,r?e[0]:a-1,e[1])})}return e.__iteratorUncached(t,n)}function K(e,t){return t?H(t,e,"",{"":e}):J(e)}function H(e,t,n,r){return Array.isArray(t)?e.call(r,n,T(t).map(function(n,r){return H(e,n,r,t)})):G(t)?e.call(r,n,O(t).map(function(n,r){return H(e,n,r,t)})):t}function J(e){return Array.isArray(e)?T(e).map(J).toList():G(e)?O(e).map(J).toMap():e}function G(e){return e&&(e.constructor===Object||void 0===e.constructor)}function X(e,t){if(e===t||e!==e&&t!==t)return!0;if(!e||!t)return!1;if("function"==typeof e.valueOf&&"function"==typeof t.valueOf){if(e=e.valueOf(),t=t.valueOf(),e===t||e!==e&&t!==t)return!0;if(!e||!t)return!1}return!("function"!=typeof e.equals||"function"!=typeof t.equals||!e.equals(t))}function Y(e,t){if(e===t)return!0;if(!o(t)||void 0!==e.size&&void 0!==t.size&&e.size!==t.size||void 0!==e.__hash&&void 0!==t.__hash&&e.__hash!==t.__hash||a(e)!==a(t)||s(e)!==s(t)||c(e)!==c(t))return!1;if(0===e.size&&0===t.size)return!0;var n=!u(e);if(c(e)){var r=e.entries();return t.every(function(e,t){var i=r.next().value;return i&&X(i[1],e)&&(n||X(i[0],t))})&&r.next().done}var i=!1;if(void 0===e.size)if(void 0===t.size)"function"==typeof e.cacheResult&&e.cacheResult();else{i=!0;var l=e;e=t,t=l}var p=!0,f=t.__iterate(function(t,r){if(n?!e.has(t):i?!X(t,e.get(r,vn)):!X(e.get(r,vn),t))return p=!1,!1});return p&&e.size===f}function $(e,t){if(!(this instanceof $))return new $(e,t);if(this._value=e,this.size=void 0===t?1/0:Math.max(0,t),0===this.size){if(An)return An;An=this}}function Z(e,t){if(!e)throw new Error(t)}function Q(e,t,n){if(!(this instanceof Q))return new Q(e,t,n);if(Z(0!==n,"Cannot step a Range by 0"),e=e||0,void 0===t&&(t=1/0),n=void 0===n?1:Math.abs(n),t>>1&1073741824|3221225471&e}function oe(e){if(!1===e||null===e||void 0===e)return 0;if("function"==typeof e.valueOf&&(!1===(e=e.valueOf())||null===e||void 0===e))return 0;if(!0===e)return 1;var t=typeof e;if("number"===t){if(e!==e||e===1/0)return 0;var n=0|e;for(n!==e&&(n^=4294967295*e);e>4294967295;)e/=4294967295,n^=e;return ie(n)}if("string"===t)return e.length>Nn?ae(e):se(e);if("function"==typeof e.hashCode)return e.hashCode();if("object"===t)return ue(e);if("function"==typeof e.toString)return se(e.toString());throw new Error("Value type "+t+" cannot be hashed.")}function ae(e){var t=Ln[e];return void 0===t&&(t=se(e),Bn===Fn&&(Bn=0,Ln={}),Bn++,Ln[e]=t),t}function se(e){for(var t=0,n=0;n0)switch(e.nodeType){case 1:return e.uniqueID;case 9:return e.documentElement&&e.documentElement.uniqueID}}function le(e){Z(e!==1/0,"Cannot perform this action with an infinite size.")}function pe(e){return null===e||void 0===e?we():fe(e)&&!c(e)?e:we().withMutations(function(t){var r=n(e);le(r.size),r.forEach(function(e,n){return t.set(n,e)})})}function fe(e){return!(!e||!e[qn])}function he(e,t){this.ownerID=e,this.entries=t}function de(e,t,n){this.ownerID=e,this.bitmap=t,this.nodes=n}function me(e,t,n){this.ownerID=e,this.count=t,this.nodes=n}function ve(e,t,n){this.ownerID=e,this.keyHash=t,this.entries=n}function ge(e,t,n){this.ownerID=e,this.keyHash=t,this.entry=n}function ye(e,t,n){this._type=t,this._reverse=n,this._stack=e._root&&be(e._root)}function _e(e,t){return w(e,t[0],t[1])}function be(e,t){return{node:e,index:0,__prev:t}}function xe(e,t,n,r){var i=Object.create(zn);return i.size=e,i._root=t,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function we(){return Un||(Un=xe(0))}function ke(e,t,n){var r,i;if(e._root){var o=l(gn),a=l(yn);if(r=Ee(e._root,e.__ownerID,0,void 0,t,n,o,a),!a.value)return e;i=e.size+(o.value?n===vn?-1:1:0)}else{if(n===vn)return e;i=1,r=new he(e.__ownerID,[[t,n]])}return e.__ownerID?(e.size=i,e._root=r,e.__hash=void 0,e.__altered=!0,e):r?xe(i,r):we()}function Ee(e,t,n,r,i,o,a,s){return e?e.update(t,n,r,i,o,a,s):o===vn?e:(p(s),p(a),new ge(t,r,[i,o]))}function Se(e){return e.constructor===ge||e.constructor===ve}function Ce(e,t,n,r,i){if(e.keyHash===r)return new ve(t,r,[e.entry,i]);var o,a=(0===n?e.keyHash:e.keyHash>>>n)&mn,s=(0===n?r:r>>>n)&mn;return new de(t,1<>>=1)a[s]=1&n?t[o++]:void 0;return a[r]=i,new me(e,o+1,a)}function Oe(e,t,r){for(var i=[],a=0;a>1&1431655765,e=(858993459&e)+(e>>2&858993459),e=e+(e>>4)&252645135,e+=e>>8,127&(e+=e>>16)}function Ne(e,t,n,r){var i=r?e:h(e);return i[t]=n,i}function Fe(e,t,n,r){var i=e.length+1;if(r&&t+1===i)return e[t]=n,e;for(var o=new Array(i),a=0,s=0;s0&&io?0:o-n,c=a-n;return c>dn&&(c=dn),function(){if(i===c)return Xn;var e=t?--c:i++;return r&&r[e]}}function i(e,r,i){var s,u=e&&e.array,c=i>o?0:o-i>>r,l=1+(a-i>>r);return l>dn&&(l=dn),function(){for(;;){if(s){var e=s();if(e!==Xn)return e;s=null}if(c===l)return Xn;var o=t?--l:c++;s=n(u&&u[o],r-hn,i+(o<=e.size||t<0)return e.withMutations(function(e){t<0?Xe(e,t).set(0,n):Xe(e,0,t+1).set(t,n)});t+=e._origin;var r=e._tail,i=e._root,o=l(yn);return t>=$e(e._capacity)?r=He(r,e.__ownerID,0,t,n,o):i=He(i,e.__ownerID,e._level,t,n,o),o.value?e.__ownerID?(e._root=i,e._tail=r,e.__hash=void 0,e.__altered=!0,e):We(e._origin,e._capacity,e._level,i,r):e}function He(e,t,n,r,i,o){var a=r>>>n&mn,s=e&&a0){var c=e&&e.array[a],l=He(c,t,n-hn,r,i,o);return l===c?e:(u=Je(e,t),u.array[a]=l,u)}return s&&e.array[a]===i?e:(p(o),u=Je(e,t),void 0===i&&a===u.array.length-1?u.array.pop():u.array[a]=i,u)}function Je(e,t){return t&&e&&t===e.ownerID?e:new ze(e?e.array.slice():[],t)}function Ge(e,t){if(t>=$e(e._capacity))return e._tail;if(t<1<0;)n=n.array[t>>>r&mn],r-=hn;return n}}function Xe(e,t,n){void 0!==t&&(t|=0),void 0!==n&&(n|=0);var r=e.__ownerID||new f,i=e._origin,o=e._capacity,a=i+t,s=void 0===n?o:n<0?o+n:i+n;if(a===i&&s===o)return e;if(a>=s)return e.clear();for(var u=e._level,c=e._root,l=0;a+l<0;)c=new ze(c&&c.array.length?[void 0,c]:[],r),u+=hn,l+=1<=1<p?new ze([],r):d;if(d&&h>p&&ahn;g-=hn){var y=p>>>g&mn;v=v.array[y]=Je(v.array[y],r)}v.array[p>>>hn&mn]=d}if(s=h)a-=h,s-=h,u=hn,c=null,m=m&&m.removeBefore(r,0,a);else if(a>i||h>>u&mn;if(_!==h>>>u&mn)break;_&&(l+=(1<i&&(c=c.removeBefore(r,u,a-l)),c&&ha&&(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 e>>hn<=dn&&a.size>=2*o.size?(i=a.filter(function(e,t){return void 0!==e&&s!==t}),r=i.toKeyedSeq().map(function(e){return e[0]}).flip().toMap(),e.__ownerID&&(r.__ownerID=i.__ownerID=e.__ownerID)):(r=o.remove(t),i=s===a.size-1?a.pop():a.set(s,void 0))}else if(u){if(n===a.get(s)[1])return e;r=o,i=a.set(s,[t,n])}else r=o.set(t,a.size),i=a.set(a.size,[t,n]);return e.__ownerID?(e.size=r.size,e._map=r,e._list=i,e.__hash=void 0,e):et(r,i)}function rt(e,t){this._iter=e,this._useKeys=t,this.size=e.size}function it(e){this._iter=e,this.size=e.size}function ot(e){this._iter=e,this.size=e.size}function at(e){this._iter=e,this.size=e.size}function st(e){var t=Dt(e);return t._iter=e,t.size=e.size,t.flip=function(){return e},t.reverse=function(){var t=e.reverse.apply(this);return t.flip=function(){return e.reverse()},t},t.has=function(t){return e.includes(t)},t.includes=function(t){return e.has(t)},t.cacheResult=Mt,t.__iterateUncached=function(t,n){var r=this;return e.__iterate(function(e,n){return!1!==t(n,e,r)},n)},t.__iteratorUncached=function(t,n){if(t===xn){var r=e.__iterator(t,n);return new x(function(){var e=r.next();if(!e.done){var t=e.value[0];e.value[0]=e.value[1],e.value[1]=t}return e})}return e.__iterator(t===bn?_n:bn,n)},t}function ut(e,t,n){var r=Dt(e);return r.size=e.size,r.has=function(t){return e.has(t)},r.get=function(r,i){var o=e.get(r,vn);return o===vn?i:t.call(n,o,r,e)},r.__iterateUncached=function(r,i){var o=this;return e.__iterate(function(e,i,a){return!1!==r(t.call(n,e,i,a),i,o)},i)},r.__iteratorUncached=function(r,i){var o=e.__iterator(xn,i);return new x(function(){var i=o.next();if(i.done)return i;var a=i.value,s=a[0];return w(r,s,t.call(n,a[1],s,e),i)})},r}function ct(e,t){var n=Dt(e);return n._iter=e,n.size=e.size,n.reverse=function(){return e},e.flip&&(n.flip=function(){var t=st(e);return t.reverse=function(){return e.flip()},t}),n.get=function(n,r){return e.get(t?n:-1-n,r)},n.has=function(n){return e.has(t?n:-1-n)},n.includes=function(t){return e.includes(t)},n.cacheResult=Mt,n.__iterate=function(t,n){var r=this;return e.__iterate(function(e,n){return t(e,n,r)},!n)},n.__iterator=function(t,n){return e.__iterator(t,!n)},n}function lt(e,t,n,r){var i=Dt(e);return r&&(i.has=function(r){var i=e.get(r,vn);return i!==vn&&!!t.call(n,i,r,e)},i.get=function(r,i){var o=e.get(r,vn);return o!==vn&&t.call(n,o,r,e)?o:i}),i.__iterateUncached=function(i,o){var a=this,s=0;return e.__iterate(function(e,o,u){if(t.call(n,e,o,u))return s++,i(e,r?o:s-1,a)},o),s},i.__iteratorUncached=function(i,o){var a=e.__iterator(xn,o),s=0;return new x(function(){for(;;){var o=a.next();if(o.done)return o;var u=o.value,c=u[0],l=u[1];if(t.call(n,l,c,e))return w(i,r?c:s++,l,o)}})},i}function pt(e,t,n){var r=pe().asMutable();return e.__iterate(function(i,o){r.update(t.call(n,i,o,e),0,function(e){return e+1})}),r.asImmutable()}function ft(e,t,n){var r=a(e),i=(c(e)?Ze():pe()).asMutable();e.__iterate(function(o,a){i.update(t.call(n,o,a,e),function(e){return e=e||[],e.push(r?[a,o]:o),e})});var o=At(e);return i.map(function(t){return Et(e,o(t))})}function ht(e,t,n,r){var i=e.size;if(void 0!==t&&(t|=0),void 0!==n&&(n===1/0?n=i:n|=0),g(t,n,i))return e;var o=y(t,i),a=_(n,i);if(o!==o||a!==a)return ht(e.toSeq().cacheResult(),t,n,r);var s,u=a-o;u===u&&(s=u<0?0:u);var c=Dt(e);return c.size=0===s?s:e.size&&s||void 0,!r&&F(e)&&s>=0&&(c.get=function(t,n){return t=m(this,t),t>=0&&ts)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||c0}function kt(e,n,r){var i=Dt(e);return i.size=new I(r).map(function(e){return e.size}).min(),i.__iterate=function(e,t){for(var n,r=this.__iterator(bn,t),i=0;!(n=r.next()).done&&!1!==e(n.value,i++,this););return i},i.__iteratorUncached=function(e,i){var o=r.map(function(e){return e=t(e),C(i?e.reverse():e)}),a=0,s=!1;return new x(function(){var t;return s||(t=o.map(function(e){return e.next()}),s=t.some(function(e){return e.done})),s?k():w(e,a++,n.apply(null,t.map(function(e){return e.value})))})},i}function Et(e,t){return F(e)?t:e.constructor(t)}function St(e){if(e!==Object(e))throw new TypeError("Expected [K, V] tuple: "+e)}function Ct(e){return le(e.size),d(e)}function At(e){return a(e)?n:s(e)?r:i}function Dt(e){return Object.create((a(e)?O:s(e)?T:P).prototype)}function Mt(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):M.prototype.cacheResult.call(this)}function Ot(e,t){return e>t?1:et?-1:0}function on(e){if(e.size===1/0)return 0;var t=c(e),n=a(e),r=t?1:0;return an(e.__iterate(n?t?function(e,t){r=31*r+sn(oe(e),oe(t))|0}:function(e,t){r=r+sn(oe(e),oe(t))|0}:t?function(e){r=31*r+oe(e)|0}:function(e){r=r+oe(e)|0}),r)}function an(e,t){return t=On(t,3432918353),t=On(t<<15|t>>>-15,461845907),t=On(t<<13|t>>>-13,5),t=(t+3864292196|0)^e,t=On(t^t>>>16,2246822507),t=On(t^t>>>13,3266489909),t=ie(t^t>>>16)}function sn(e,t){return e^t+2654435769+(e<<6)+(e>>2)|0}var un=Array.prototype.slice;e(n,t),e(r,t),e(i,t),t.isIterable=o,t.isKeyed=a,t.isIndexed=s,t.isAssociative=u,t.isOrdered=c,t.Keyed=n,t.Indexed=r,t.Set=i;var cn="@@__IMMUTABLE_ITERABLE__@@",ln="@@__IMMUTABLE_KEYED__@@",pn="@@__IMMUTABLE_INDEXED__@@",fn="@@__IMMUTABLE_ORDERED__@@",hn=5,dn=1<r?k():w(e,i,n[t?r-i++:i++])})},e(j,O),j.prototype.get=function(e,t){return void 0===t||this.has(e)?this._object[e]:t},j.prototype.has=function(e){return this._object.hasOwnProperty(e)},j.prototype.__iterate=function(e,t){for(var n=this._object,r=this._keys,i=r.length-1,o=0;o<=i;o++){var a=r[t?i-o:o];if(!1===e(n[a],a,this))return o+1}return o},j.prototype.__iterator=function(e,t){var n=this._object,r=this._keys,i=r.length-1,o=0;return new x(function(){var a=r[t?i-o:o];return o++>i?k():w(e,a,n[a])})},j.prototype[fn]=!0,e(R,T),R.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);var n=this._iterable,r=C(n),i=0;if(S(r))for(var o;!(o=r.next()).done&&!1!==e(o.value,i++,this););return i},R.prototype.__iteratorUncached=function(e,t){if(t)return this.cacheResult().__iterator(e,t);var n=this._iterable,r=C(n);if(!S(r))return new x(k);var i=0;return new x(function(){var t=r.next();return t.done?t:w(e,i++,t.value)})},e(N,T),N.prototype.__iterateUncached=function(e,t){if(t)return this.cacheResult().__iterate(e,t);for(var n=this._iterator,r=this._iteratorCache,i=0;i=r.length){var t=n.next();if(t.done)return t;r[i]=t.value}return w(e,i,r[i++])})};var Cn;e($,T),$.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},$.prototype.get=function(e,t){return this.has(e)?this._value:t},$.prototype.includes=function(e){return X(this._value,e)},$.prototype.slice=function(e,t){var n=this.size;return g(e,t,n)?this:new $(this._value,_(t,n)-y(e,n))},$.prototype.reverse=function(){return this},$.prototype.indexOf=function(e){return X(this._value,e)?0:-1},$.prototype.lastIndexOf=function(e){return X(this._value,e)?this.size:-1},$.prototype.__iterate=function(e,t){for(var n=0;n=0&&t=0&&nn?k():w(e,o++,a)})},Q.prototype.equals=function(e){return e instanceof Q?this._start===e._start&&this._end===e._end&&this._step===e._step:Y(this,e)};var Dn;e(ee,t),e(te,ee),e(ne,ee),e(re,ee),ee.Keyed=te,ee.Indexed=ne,ee.Set=re;var Mn,On="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function(e,t){e|=0,t|=0;var n=65535&e,r=65535&t;return n*r+((e>>>16)*r+n*(t>>>16)<<16>>>0)|0},Tn=Object.isExtensible,Pn=function(){try{return Object.defineProperty({},"@",{}),!0}catch(e){return!1}}(),In="function"==typeof WeakMap;In&&(Mn=new WeakMap);var jn=0,Rn="__immutablehash__";"function"==typeof Symbol&&(Rn=Symbol(Rn));var Nn=16,Fn=255,Bn=0,Ln={};e(pe,te),pe.of=function(){var e=un.call(arguments,0);return we().withMutations(function(t){for(var n=0;n=e.length)throw new Error("Missing value for key: "+e[n]);t.set(e[n],e[n+1])}})},pe.prototype.toString=function(){return this.__toString("Map {","}")},pe.prototype.get=function(e,t){return this._root?this._root.get(0,void 0,e,t):t},pe.prototype.set=function(e,t){return ke(this,e,t)},pe.prototype.setIn=function(e,t){return this.updateIn(e,vn,function(){return t})},pe.prototype.remove=function(e){return ke(this,e,vn)},pe.prototype.deleteIn=function(e){return this.updateIn(e,function(){return vn})},pe.prototype.update=function(e,t,n){return 1===arguments.length?e(this):this.updateIn([e],t,n)},pe.prototype.updateIn=function(e,t,n){n||(n=t,t=void 0);var r=je(this,Tt(e),t,n);return r===vn?void 0:r},pe.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):we()},pe.prototype.merge=function(){return Oe(this,void 0,arguments)},pe.prototype.mergeWith=function(e){return Oe(this,e,un.call(arguments,1))},pe.prototype.mergeIn=function(e){var t=un.call(arguments,1);return this.updateIn(e,we(),function(e){return"function"==typeof e.merge?e.merge.apply(e,t):t[t.length-1]})},pe.prototype.mergeDeep=function(){return Oe(this,Te,arguments)},pe.prototype.mergeDeepWith=function(e){var t=un.call(arguments,1);return Oe(this,Pe(e),t)},pe.prototype.mergeDeepIn=function(e){var t=un.call(arguments,1);return this.updateIn(e,we(),function(e){return"function"==typeof e.mergeDeep?e.mergeDeep.apply(e,t):t[t.length-1]})},pe.prototype.sort=function(e){return Ze(bt(this,e))},pe.prototype.sortBy=function(e,t){return Ze(bt(this,t,e))},pe.prototype.withMutations=function(e){var t=this.asMutable();return e(t),t.wasAltered()?t.__ensureOwner(this.__ownerID):this},pe.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new f)},pe.prototype.asImmutable=function(){return this.__ensureOwner()},pe.prototype.wasAltered=function(){return this.__altered},pe.prototype.__iterator=function(e,t){return new ye(this,e,t)},pe.prototype.__iterate=function(e,t){var n=this,r=0;return this._root&&this._root.iterate(function(t){return r++,e(t[1],t[0],n)},t),r},pe.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?xe(this.size,this._root,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},pe.isMap=fe;var qn="@@__IMMUTABLE_MAP__@@",zn=pe.prototype;zn[qn]=!0,zn.delete=zn.remove,zn.removeIn=zn.deleteIn,he.prototype.get=function(e,t,n,r){for(var i=this.entries,o=0,a=i.length;o=Wn)return Ae(e,u,r,i);var d=e&&e===this.ownerID,m=d?u:h(u);return f?s?c===l-1?m.pop():m[c]=m.pop():m[c]=[r,i]:m.push([r,i]),d?(this.entries=m,this):new he(e,m)}},de.prototype.get=function(e,t,n,r){void 0===t&&(t=oe(n));var i=1<<((0===e?t:t>>>e)&mn),o=this.bitmap;return 0==(o&i)?r:this.nodes[Re(o&i-1)].get(e+hn,t,n,r)},de.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=1<=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&&--f=0&&e>>t&mn;if(r>=this.array.length)return new ze([],e);var i,o=0===r;if(t>0){var a=this.array[r];if((i=a&&a.removeBefore(e,t-hn,n))===a&&o)return this}if(o&&!i)return this;var s=Je(this,e);if(!o)for(var u=0;u>>t&mn;if(r>=this.array.length)return this;var i;if(t>0){var o=this.array[r];if((i=o&&o.removeAfter(e,t-hn,n))===o&&r===this.array.length-1)return this}var a=Je(this,e);return a.array.splice(r+1),i&&(a.array[r]=i),a};var Gn,Xn={};e(Ze,pe),Ze.of=function(){return this(arguments)},Ze.prototype.toString=function(){return this.__toString("OrderedMap {","}")},Ze.prototype.get=function(e,t){var n=this._map.get(e);return void 0!==n?this._list.get(n)[1]:t},Ze.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):tt()},Ze.prototype.set=function(e,t){return nt(this,e,t)},Ze.prototype.remove=function(e){return nt(this,e,vn)},Ze.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},Ze.prototype.__iterate=function(e,t){var n=this;return this._list.__iterate(function(t){return t&&e(t[1],t[0],n)},t)},Ze.prototype.__iterator=function(e,t){return this._list.fromEntrySeq().__iterator(e,t)},Ze.prototype.__ensureOwner=function(e){if(e===this.__ownerID)return this;var t=this._map.__ensureOwner(e),n=this._list.__ensureOwner(e);return e?et(t,n,e,this.__hash):(this.__ownerID=e,this._map=t,this._list=n,this)},Ze.isOrderedMap=Qe,Ze.prototype[fn]=!0,Ze.prototype.delete=Ze.prototype.remove;var Yn;e(rt,O),rt.prototype.get=function(e,t){return this._iter.get(e,t)},rt.prototype.has=function(e){return this._iter.has(e)},rt.prototype.valueSeq=function(){return this._iter.valueSeq()},rt.prototype.reverse=function(){var e=this,t=ct(this,!0);return this._useKeys||(t.valueSeq=function(){return e._iter.toSeq().reverse()}),t},rt.prototype.map=function(e,t){var n=this,r=ut(this,e,t);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(e,t)}),r},rt.prototype.__iterate=function(e,t){var n,r=this;return this._iter.__iterate(this._useKeys?function(t,n){return e(t,n,r)}:(n=t?Ct(this):0,function(i){return e(i,t?--n:n++,r)}),t)},rt.prototype.__iterator=function(e,t){if(this._useKeys)return this._iter.__iterator(e,t);var n=this._iter.__iterator(bn,t),r=t?Ct(this):0;return new x(function(){var i=n.next();return i.done?i:w(e,t?--r:r++,i.value,i)})},rt.prototype[fn]=!0,e(it,T),it.prototype.includes=function(e){return this._iter.includes(e)},it.prototype.__iterate=function(e,t){var n=this,r=0;return this._iter.__iterate(function(t){return e(t,r++,n)},t)},it.prototype.__iterator=function(e,t){var n=this._iter.__iterator(bn,t),r=0;return new x(function(){var t=n.next();return t.done?t:w(e,r++,t.value,t)})},e(ot,P),ot.prototype.has=function(e){return this._iter.includes(e)},ot.prototype.__iterate=function(e,t){var n=this;return this._iter.__iterate(function(t){return e(t,t,n)},t)},ot.prototype.__iterator=function(e,t){var n=this._iter.__iterator(bn,t);return new x(function(){var t=n.next();return t.done?t:w(e,t.value,t.value,t)})},e(at,O),at.prototype.entrySeq=function(){return this._iter.toSeq()},at.prototype.__iterate=function(e,t){var n=this;return this._iter.__iterate(function(t){if(t){St(t);var r=o(t);return e(r?t.get(1):t[1],r?t.get(0):t[0],n)}},t)},at.prototype.__iterator=function(e,t){var n=this._iter.__iterator(bn,t);return new x(function(){for(;;){var t=n.next();if(t.done)return t;var r=t.value;if(r){St(r);var i=o(r);return w(e,i?r.get(0):r[0],i?r.get(1):r[1],t)}}})},it.prototype.cacheResult=rt.prototype.cacheResult=ot.prototype.cacheResult=at.prototype.cacheResult=Mt,e(Pt,te),Pt.prototype.toString=function(){return this.__toString(jt(this)+" {","}")},Pt.prototype.has=function(e){return this._defaultValues.hasOwnProperty(e)},Pt.prototype.get=function(e,t){if(!this.has(e))return t;var n=this._defaultValues[e];return this._map?this._map.get(e,n):n},Pt.prototype.clear=function(){if(this.__ownerID)return this._map&&this._map.clear(),this;var e=this.constructor;return e._empty||(e._empty=It(this,we()))},Pt.prototype.set=function(e,t){if(!this.has(e))throw new Error('Cannot set unknown key "'+e+'" on '+jt(this));if(this._map&&!this._map.has(e)){if(t===this._defaultValues[e])return this}var n=this._map&&this._map.set(e,t);return this.__ownerID||n===this._map?this:It(this,n)},Pt.prototype.remove=function(e){if(!this.has(e))return this;var t=this._map&&this._map.remove(e);return this.__ownerID||t===this._map?this:It(this,t)},Pt.prototype.wasAltered=function(){return this._map.wasAltered()},Pt.prototype.__iterator=function(e,t){var r=this;return n(this._defaultValues).map(function(e,t){return r.get(t)}).__iterator(e,t)},Pt.prototype.__iterate=function(e,t){var r=this;return n(this._defaultValues).map(function(e,t){return r.get(t)}).__iterate(e,t)},Pt.prototype.__ensureOwner=function(e){if(e===this.__ownerID)return this;var t=this._map&&this._map.__ensureOwner(e);return e?It(this,t,e):(this.__ownerID=e,this._map=t,this)};var $n=Pt.prototype;$n.delete=$n.remove,$n.deleteIn=$n.removeIn=zn.removeIn,$n.merge=zn.merge,$n.mergeWith=zn.mergeWith,$n.mergeIn=zn.mergeIn,$n.mergeDeep=zn.mergeDeep,$n.mergeDeepWith=zn.mergeDeepWith,$n.mergeDeepIn=zn.mergeDeepIn,$n.setIn=zn.setIn,$n.update=zn.update,$n.updateIn=zn.updateIn,$n.withMutations=zn.withMutations,$n.asMutable=zn.asMutable,$n.asImmutable=zn.asImmutable,e(Ft,re),Ft.of=function(){return this(arguments)},Ft.fromKeys=function(e){return this(n(e).keySeq())},Ft.prototype.toString=function(){return this.__toString("Set {","}")},Ft.prototype.has=function(e){return this._map.has(e)},Ft.prototype.add=function(e){return Lt(this,this._map.set(e,!0))},Ft.prototype.remove=function(e){return Lt(this,this._map.remove(e))},Ft.prototype.clear=function(){return Lt(this,this._map.clear())},Ft.prototype.union=function(){var e=un.call(arguments,0);return e=e.filter(function(e){return 0!==e.size}),0===e.length?this:0!==this.size||this.__ownerID||1!==e.length?this.withMutations(function(t){for(var n=0;n=0;n--)t={value:arguments[n],next:t};return this.__ownerID?(this.size=e,this._head=t,this.__hash=void 0,this.__altered=!0,this):Gt(e,t)},Ht.prototype.pushAll=function(e){if(e=r(e),0===e.size)return this;le(e.size);var t=this.size,n=this._head;return e.reverse().forEach(function(e){t++,n={value:e,next:n}}),this.__ownerID?(this.size=t,this._head=n,this.__hash=void 0,this.__altered=!0,this):Gt(t,n)},Ht.prototype.pop=function(){return this.slice(1)},Ht.prototype.unshift=function(){return this.push.apply(this,arguments)},Ht.prototype.unshiftAll=function(e){return this.pushAll(e)},Ht.prototype.shift=function(){return this.pop.apply(this,arguments)},Ht.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Xt()},Ht.prototype.slice=function(e,t){if(g(e,t,this.size))return this;var n=y(e,this.size);if(_(t,this.size)!==this.size)return ne.prototype.slice.call(this,e,t);for(var r=this.size-n,i=this._head;n--;)i=i.next;return this.__ownerID?(this.size=r,this._head=i,this.__hash=void 0,this.__altered=!0,this):Gt(r,i)},Ht.prototype.__ensureOwner=function(e){return e===this.__ownerID?this:e?Gt(this.size,this._head,e,this.__hash):(this.__ownerID=e,this.__altered=!1,this)},Ht.prototype.__iterate=function(e,t){if(t)return this.reverse().__iterate(e);for(var n=0,r=this._head;r&&!1!==e(r.value,n++,this);)r=r.next;return n},Ht.prototype.__iterator=function(e,t){if(t)return this.reverse().__iterator(e);var n=0,r=this._head;return new x(function(){if(r){var t=r.value;return r=r.next,w(e,n++,t)}return k()})},Ht.isStack=Jt;var rr="@@__IMMUTABLE_STACK__@@",ir=Ht.prototype;ir[rr]=!0,ir.withMutations=zn.withMutations,ir.asMutable=zn.asMutable,ir.asImmutable=zn.asImmutable,ir.wasAltered=zn.wasAltered;var or;t.Iterator=x,Yt(t,{toArray:function(){le(this.size);var e=new Array(this.size||0);return this.valueSeq().__iterate(function(t,n){e[n]=t}),e},toIndexedSeq:function(){return new it(this)},toJS:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJS?e.toJS():e}).__toJS()},toJSON:function(){return this.toSeq().map(function(e){return e&&"function"==typeof e.toJSON?e.toJSON():e}).__toJS()},toKeyedSeq:function(){return new rt(this,!0)},toMap:function(){return pe(this.toKeyedSeq())},toObject:function(){le(this.size);var e={};return this.__iterate(function(t,n){e[n]=t}),e},toOrderedMap:function(){return Ze(this.toKeyedSeq())},toOrderedSet:function(){return Ut(a(this)?this.valueSeq():this)},toSet:function(){return Ft(a(this)?this.valueSeq():this)},toSetSeq:function(){return new ot(this)},toSeq:function(){return s(this)?this.toIndexedSeq():a(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Ht(a(this)?this.valueSeq():this)},toList:function(){return Le(a(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(e,t){return 0===this.size?e+t:e+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+t},concat:function(){return Et(this,vt(this,un.call(arguments,0)))},includes:function(e){return this.some(function(t){return X(t,e)})},entries:function(){return this.__iterator(xn)},every:function(e,t){le(this.size);var n=!0;return this.__iterate(function(r,i,o){if(!e.call(t,r,i,o))return n=!1,!1}),n},filter:function(e,t){return Et(this,lt(this,e,t,!0))},find:function(e,t,n){var r=this.findEntry(e,t);return r?r[1]:n},forEach:function(e,t){return le(this.size),this.__iterate(t?e.bind(t):e)},join:function(e){le(this.size),e=void 0!==e?""+e:",";var t="",n=!0;return this.__iterate(function(r){n?n=!1:t+=e,t+=null!==r&&void 0!==r?r.toString():""}),t},keys:function(){return this.__iterator(_n)},map:function(e,t){return Et(this,ut(this,e,t))},reduce:function(e,t,n){le(this.size);var r,i;return arguments.length<2?i=!0:r=t,this.__iterate(function(t,o,a){i?(i=!1,r=t):r=e.call(n,r,t,o,a)}),r},reduceRight:function(e,t,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return Et(this,ct(this,!0))},slice:function(e,t){return Et(this,ht(this,e,t,!0))},some:function(e,t){return!this.every(Qt(e),t)},sort:function(e){return Et(this,bt(this,e))},values:function(){return this.__iterator(bn)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some(function(){return!0})},count:function(e,t){return d(e?this.toSeq().filter(e,t):this)},countBy:function(e,t){return pt(this,e,t)},equals:function(e){return Y(this,e)},entrySeq:function(){var e=this;if(e._cache)return new I(e._cache);var t=e.toSeq().map(Zt).toIndexedSeq();return t.fromEntrySeq=function(){return e.toSeq()},t},filterNot:function(e,t){return this.filter(Qt(e),t)},findEntry:function(e,t,n){var r=n;return this.__iterate(function(n,i,o){if(e.call(t,n,i,o))return r=[i,n],!1}),r},findKey:function(e,t){var n=this.findEntry(e,t);return n&&n[0]},findLast:function(e,t,n){return this.toKeyedSeq().reverse().find(e,t,n)},findLastEntry:function(e,t,n){return this.toKeyedSeq().reverse().findEntry(e,t,n)},findLastKey:function(e,t){return this.toKeyedSeq().reverse().findKey(e,t)},first:function(){return this.find(v)},flatMap:function(e,t){return Et(this,yt(this,e,t))},flatten:function(e){return Et(this,gt(this,e,!0))},fromEntrySeq:function(){return new at(this)},get:function(e,t){return this.find(function(t,n){return X(n,e)},void 0,t)},getIn:function(e,t){for(var n,r=this,i=Tt(e);!(n=i.next()).done;){var o=n.value;if((r=r&&r.get?r.get(o,vn):vn)===vn)return t}return r},groupBy:function(e,t){return ft(this,e,t)},has:function(e){return this.get(e,vn)!==vn},hasIn:function(e){return this.getIn(e,vn)!==vn},isSubset:function(e){return e="function"==typeof e.includes?e:t(e),this.every(function(t){return e.includes(t)})},isSuperset:function(e){return e="function"==typeof e.isSubset?e:t(e),e.isSubset(this)},keyOf:function(e){return this.findKey(function(t){return X(t,e)})},keySeq:function(){return this.toSeq().map($t).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(e){return this.toKeyedSeq().reverse().keyOf(e)},max:function(e){return xt(this,e)},maxBy:function(e,t){return xt(this,t,e)},min:function(e){return xt(this,e?en(e):rn)},minBy:function(e,t){return xt(this,t?en(t):rn,e)},rest:function(){return this.slice(1)},skip:function(e){return this.slice(Math.max(0,e))},skipLast:function(e){return Et(this,this.toSeq().reverse().skip(e).reverse())},skipWhile:function(e,t){return Et(this,mt(this,e,t,!0))},skipUntil:function(e,t){return this.skipWhile(Qt(e),t)},sortBy:function(e,t){return Et(this,bt(this,t,e))},take:function(e){return this.slice(0,Math.max(0,e))},takeLast:function(e){return Et(this,this.toSeq().reverse().take(e).reverse())},takeWhile:function(e,t){return Et(this,dt(this,e,t))},takeUntil:function(e,t){return this.takeWhile(Qt(e),t)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=on(this))}});var ar=t.prototype;ar[cn]=!0,ar[En]=ar.values,ar.__toJS=ar.toArray,ar.__toStringMapper=tn,ar.inspect=ar.toSource=function(){return this.toString()},ar.chain=ar.flatMap,ar.contains=ar.includes,Yt(n,{flip:function(){return Et(this,st(this))},mapEntries:function(e,t){var n=this,r=0;return Et(this,this.toSeq().map(function(i,o){return e.call(t,[o,i],r++,n)}).fromEntrySeq())},mapKeys:function(e,t){var n=this;return Et(this,this.toSeq().flip().map(function(r,i){return e.call(t,r,i,n)}).flip())}});var sr=n.prototype;return sr[ln]=!0,sr[En]=ar.entries,sr.__toJS=ar.toObject,sr.__toStringMapper=function(e,t){return JSON.stringify(t)+": "+tn(e)},Yt(r,{toKeyedSeq:function(){return new rt(this,!1)},filter:function(e,t){return Et(this,lt(this,e,t,!1))},findIndex:function(e,t){var n=this.findEntry(e,t);return n?n[0]:-1},indexOf:function(e){var t=this.keyOf(e);return void 0===t?-1:t},lastIndexOf:function(e){var t=this.lastKeyOf(e);return void 0===t?-1:t},reverse:function(){return Et(this,ct(this,!1))},slice:function(e,t){return Et(this,ht(this,e,t,!1))},splice:function(e,t){var n=arguments.length;if(t=Math.max(0|t,0),0===n||2===n&&!t)return this;e=y(e,e<0?this.count():this.size);var r=this.slice(0,e);return Et(this,1===n?r:r.concat(h(arguments,2),this.slice(e+t)))},findLastIndex:function(e,t){var n=this.findLastEntry(e,t);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(e){return Et(this,gt(this,e,!1))},get:function(e,t){return e=m(this,e),e<0||this.size===1/0||void 0!==this.size&&e>this.size?t:this.find(function(t,n){return n===e},void 0,t)},has:function(e){return(e=m(this,e))>=0&&(void 0!==this.size?this.size===1/0||e)(<)(\/*)/g,f=/[ ]*(.*)[ ]+\n/g,t=/(<.+>)(.+\n)/g,e=e.replace(/\r\n/g,"\n").replace(l,"$1\n$2$3").replace(f,"$1\n").replace(t,"$1\n$2"),r="",u=e.split("\n"),i=0,a="other",p={"single->single":0,"single->closing":-1,"single->opening":0,"single->other":0,"closing->single":0,"closing->closing":-1,"closing->opening":0,"closing->other":0,"opening->single":1,"opening->closing":0,"opening->opening":1,"opening->other":1,"other->single":0,"other->closing":-1,"other->opening":0,"other->other":0},n=function(e){var t,n,o,s,u,c;u={single:Boolean(e.match(/<.+\/>/)),closing:Boolean(e.match(/<\/.+>/)),opening:Boolean(e.match(/<[^!?].*>/))},s=function(){var e;e=[];for(n in u)(c=u[n])&&e.push(n);return e}()[0],s=void 0===s?"other":s,t=a+"->"+s,a=s,o="",i+=p[t],o=function(){var e,t,n;for(n=[],e=0,t=i;0<=t?et;0<=t?++e:--e)n.push(" ");return n}().join(""),"opening->closing"===t?r=r.substr(0,r.length-1)+e+"\n":r+=o+e+"\n"},o=0,s=u.length;o5e3)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;c=55296&&e<=57343)&&(!(e>=64976&&e<=65007)&&(65535!=(65535&e)&&65534!=(65535&e)&&(!(e>=0&&e<=8)&&(11!==e&&(!(e>=14&&e<=31)&&(!(e>=127&&e<=159)&&!(e>1114111)))))))}function c(e){if(e>65535){e-=65536;var t=55296+(e>>10),n=56320+(1023&e);return String.fromCharCode(t,n)}return String.fromCharCode(e)}function l(e,t){var n=0;return o(y,t)?y[t]:35===t.charCodeAt(0)&&g.test(t)&&(n="x"===t[1].toLowerCase()?parseInt(t.slice(2),16):parseInt(t.slice(1),10),u(n))?c(n):e}function p(e){return e.indexOf("&")<0?e:e.replace(v,l)}function f(e){return x[e]}function h(e){return _.test(e)?e.replace(b,f):e}var d=Object.prototype.hasOwnProperty,m=/\\([\\!"#$%&'()*+,.\/:;<=>?@[\]^_`{|}~-])/g,v=/&([a-z#][a-z0-9]{1,31});/gi,g=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))/i,y=n(452),_=/[&<>"]/,b=/[&<>"]/g,x={"&":"&","<":"<",">":">",'"':"""};t.assign=a,t.isString=i,t.has=o,t.unescapeMd=s,t.isValidEntityCode=u,t.fromCodePoint=c,t.replaceEntities=p,t.escapeHtml=h},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){var r=n(630)("wks"),i=n(349),o=n(30).Symbol;e.exports=function(e){return r[e]||(r[e]=o&&o[e]||(o||i)("Symbol."+e))}},function(e,t,n){"use strict";function r(e){return function(){return e}}var i=function(){};i.thatReturns=r,i.thatReturnsFalse=r(!1),i.thatReturnsTrue=r(!0),i.thatReturnsNull=r(null),i.thatReturnsThis=function(){return this},i.thatReturnsArgument=function(e){return e},e.exports=i},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){"use strict";function r(e){var t=typeof e;return Array.isArray(e)?"array":e instanceof RegExp?"object":e instanceof b.Iterable?"Immutable."+e.toSource().split(" ")[0]:t}function i(e){function t(t,n,r,i,o,a){for(var s=arguments.length,u=Array(s>6?s-6:0),c=6;c5?c-5:0),p=5;p5?a-5:0),u=5;u key("+l[p]+")"].concat(s));if(h instanceof Error)return h}}return i(t)}function u(e){return a(e,"List",b.List.isList)}function c(e,t,n,r){function o(){for(var i=arguments.length,o=Array(i),u=0;u5?s-5:0),c=5;c5?c-5:0),p=5;p>",w={listOf:u,mapOf:l,orderedMapOf:p,setOf:f,orderedSetOf:h,stackOf:d,iterableOf:m,recordOf:v,shape:y,contains:y,mapContains:_,list:o("List",b.List.isList),map:o("Map",b.Map.isMap),orderedMap:o("OrderedMap",b.OrderedMap.isOrderedMap),set:o("Set",b.Set.isSet),orderedSet:o("OrderedSet",b.OrderedSet.isOrderedSet),stack:o("Stack",b.Stack.isStack),seq:o("Seq",b.Seq.isSeq),record:o("Record",function(e){return e instanceof b.Record}),iterable:o("Iterable",b.Iterable.isIterable)};e.exports=w},function(e,t,n){var r=n(27);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t){var n=e.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(e,t){function n(){throw new Error("setTimeout has not been defined")}function r(){throw new Error("clearTimeout has not been defined")}function i(e){if(l===setTimeout)return setTimeout(e,0);if((l===n||!l)&&setTimeout)return l=setTimeout,setTimeout(e,0);try{return l(e,0)}catch(t){try{return l.call(null,e,0)}catch(t){return l.call(this,e,0)}}}function o(e){if(p===clearTimeout)return clearTimeout(e);if((p===r||!p)&&clearTimeout)return p=clearTimeout,clearTimeout(e);try{return p(e)}catch(t){try{return p.call(null,e)}catch(t){return p.call(this,e)}}}function a(){m&&h&&(m=!1,h.length?d=h.concat(d):v=-1,d.length&&s())}function s(){if(!m){var e=i(a);m=!0;for(var t=d.length;t;){for(h=d,d=[];++v1)for(var n=1;n0&&(a=this.buffer[u-1],e.call(r,a)<0);)if(u--,this.pointer-u>n/2-1){o=" ... ",u+=5;break}for(c="",i=this.pointer;in/2-1){c=" ... ",i-=5;break}return""+new Array(t).join(" ")+o+this.buffer.slice(u,i)+c+"\n"+new Array(t+this.pointer-u+o.length).join(" ")+"^"},t.prototype.toString=function(){var e,t;return e=this.get_snippet(),t=" on line "+(this.line+1)+", column "+(this.column+1),e?t:t+":\n"+e},t}(),this.YAMLError=function(e){function n(e){this.message=e,n.__super__.constructor.call(this),this.stack=this.toString()+"\n"+(new Error).stack.split("\n").slice(1).join("\n")}return t(n,e),n.prototype.toString=function(){return this.message},n}(Error),this.MarkedYAMLError=function(e){function n(e,t,r,i,o){this.context=e,this.context_mark=t,this.problem=r,this.problem_mark=i,this.note=o,n.__super__.constructor.call(this)}return t(n,e),n.prototype.toString=function(){var e;return e=[],null!=this.context&&e.push(this.context),null==this.context_mark||null!=this.problem&&null!=this.problem_mark&&this.context_mark.line===this.problem_mark.line&&this.context_mark.column===this.problem_mark.column||e.push(this.context_mark.toString()),null!=this.problem&&e.push(this.problem),null!=this.problem_mark&&e.push(this.problem_mark.toString()),null!=this.note&&e.push(this.note),e.join("\n")},n}(this.YAMLError)}).call(this)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e){var t=e.get("openapi");return!!t&&t.startsWith("3.0.0")}function o(e){var t=e.get("swagger");return!!t&&t.startsWith("2")}function a(e){return function(t,n){return function(r){if(n&&n.specSelectors&&n.specSelectors.specJson){return i(n.specSelectors.specJson())?l.default.createElement(e,(0,u.default)({},r,n,{Ori:t})):l.default.createElement(t,r)}return console.warn("OAS3 wrapper: couldn't get spec"),null}}}Object.defineProperty(t,"__esModule",{value:!0});var s=n(18),u=r(s);t.isOAS3=i,t.isSwagger2=o,t.OAS3ComponentWrapFactory=a;var c=n(0),l=r(c)},function(e,t,n){e.exports={default:n(546),__esModule:!0}},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}t.__esModule=!0;var i=n(541),o=r(i),a=n(540),s=r(a),u="function"==typeof s.default&&"symbol"==typeof o.default?function(e){return typeof e}:function(e){return e&&"function"==typeof s.default&&e.constructor===s.default&&e!==s.default.prototype?"symbol":typeof e};t.default="function"==typeof s.default&&"symbol"===u(o.default)?function(e){return void 0===e?"undefined":u(e)}:function(e){return e&&"function"==typeof s.default&&e.constructor===s.default&&e!==s.default.prototype?"symbol":void 0===e?"undefined":u(e)}},function(e,t,n){e.exports=!n(55)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,t,n){"use strict";(function(e){function r(){return o.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function i(e,t){if(r()=r())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+r().toString(16)+" bytes");return 0|e}function m(e){return+e!=e&&(e=0),o.alloc(+e)}function v(e,t){if(o.isBuffer(e))return e.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(e)||e instanceof ArrayBuffer))return e.byteLength;"string"!=typeof e&&(e=""+e);var n=e.length;if(0===n)return 0;for(var r=!1;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":case void 0:return V(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return J(e).length;default:if(r)return V(e).length;t=(""+t).toLowerCase(),r=!0}}function g(e,t,n){var r=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if(n>>>=0,t>>>=0,n<=t)return"";for(e||(e="utf8");;)switch(e){case"hex":return P(this,t,n);case"utf8":case"utf-8":return D(this,t,n);case"ascii":return O(this,t,n);case"latin1":case"binary":return T(this,t,n);case"base64":return A(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return I(this,t,n);default:if(r)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),r=!0}}function y(e,t,n){var r=e[t];e[t]=e[n],e[n]=r}function _(e,t,n,r,i){if(0===e.length)return-1;if("string"==typeof n?(r=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),n=+n,isNaN(n)&&(n=i?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(i)return-1;n=e.length-1}else if(n<0){if(!i)return-1;n=0}if("string"==typeof t&&(t=o.from(t,r)),o.isBuffer(t))return 0===t.length?-1:b(e,t,n,r,i);if("number"==typeof t)return t&=255,o.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):b(e,[t],n,r,i);throw new TypeError("val must be string, number or Buffer")}function b(e,t,n,r,i){function o(e,t){return 1===a?e[t]:e.readUInt16BE(t*a)}var a=1,s=e.length,u=t.length;if(void 0!==r&&("ucs2"===(r=String(r).toLowerCase())||"ucs-2"===r||"utf16le"===r||"utf-16le"===r)){if(e.length<2||t.length<2)return-1;a=2,s/=2,u/=2,n/=2}var c;if(i){var l=-1;for(c=n;cs&&(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;a239?4:o>223?3:o>191?2:1;if(i+s<=n){var u,c,l,p;switch(s){case 1:o<128&&(a=o);break;case 2:u=e[i+1],128==(192&u)&&(p=(31&o)<<6|63&u)>127&&(a=p);break;case 3:u=e[i+1],c=e[i+2],128==(192&u)&&128==(192&c)&&(p=(15&o)<<12|(63&u)<<6|63&c)>2047&&(p<55296||p>57343)&&(a=p);break;case 4:u=e[i+1],c=e[i+2],l=e[i+3],128==(192&u)&&128==(192&c)&&128==(192&l)&&(p=(15&o)<<18|(63&u)<<12|(63&c)<<6|63&l)>65535&&p<1114112&&(a=p)}}null===a?(a=65533,s=1):a>65535&&(a-=65536,r.push(a>>>10&1023|55296),a=56320|1023&a),r.push(a),i+=s}return M(r)}function M(e){var t=e.length;if(t<=Q)return String.fromCharCode.apply(String,e);for(var n="",r=0;rr)&&(n=r);for(var i="",o=t;on)throw new RangeError("Trying to access beyond buffer length")}function R(e,t,n,r,i,a){if(!o.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>i||te.length)throw new RangeError("Index out of range")}function N(e,t,n,r){t<0&&(t=65535+t+1);for(var i=0,o=Math.min(e.length-n,2);i>>8*(r?i:1-i)}function F(e,t,n,r){t<0&&(t=4294967295+t+1);for(var i=0,o=Math.min(e.length-n,4);i>>8*(r?i:3-i)&255}function B(e,t,n,r,i,o){if(n+r>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function L(e,t,n,r,i){return i||B(e,t,n,4,3.4028234663852886e38,-3.4028234663852886e38),$.write(e,t,n,r,23,4),n+4}function q(e,t,n,r,i){return i||B(e,t,n,8,1.7976931348623157e308,-1.7976931348623157e308),$.write(e,t,n,r,52,8),n+8}function z(e){if(e=U(e).replace(ee,""),e.length<2)return"";for(;e.length%4!=0;)e+="=";return e}function U(e){return e.trim?e.trim():e.replace(/^\s+|\s+$/g,"")}function W(e){return e<16?"0"+e.toString(16):e.toString(16)}function V(e,t){t=t||1/0;for(var n,r=e.length,i=null,o=[],a=0;a55295&&n<57344){if(!i){if(n>56319){(t-=3)>-1&&o.push(239,191,189);continue}if(a+1===r){(t-=3)>-1&&o.push(239,191,189);continue}i=n;continue}if(n<56320){(t-=3)>-1&&o.push(239,191,189),i=n;continue}n=65536+(i-55296<<10|n-56320)}else i&&(t-=3)>-1&&o.push(239,191,189);if(i=null,n<128){if((t-=1)<0)break;o.push(n)}else if(n<2048){if((t-=2)<0)break;o.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;o.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;o.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return o}function K(e){for(var t=[],n=0;n>8,i=n%256,o.push(i),o.push(r);return o}function J(e){return Y.toByteArray(z(e))}function G(e,t,n,r){for(var i=0;i=t.length||i>=e.length);++i)t[i+n]=e[i];return i}function X(e){return e!==e}/*! + * The buffer module from node.js, for the browser. + * + * @author Feross Aboukhadijeh + * @license MIT + */ +var Y=n(597),$=n(709),Z=n(369);t.Buffer=o,t.SlowBuffer=m,t.INSPECT_MAX_BYTES=50,o.TYPED_ARRAY_SUPPORT=void 0!==e.TYPED_ARRAY_SUPPORT?e.TYPED_ARRAY_SUPPORT:function(){try{var e=new Uint8Array(1);return e.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}},42===e.foo()&&"function"==typeof e.subarray&&0===e.subarray(1,1).byteLength}catch(e){return!1}}(),t.kMaxLength=r(),o.poolSize=8192,o._augment=function(e){return e.__proto__=o.prototype,e},o.from=function(e,t,n){return a(null,e,t,n)},o.TYPED_ARRAY_SUPPORT&&(o.prototype.__proto__=Uint8Array.prototype,o.__proto__=Uint8Array,"undefined"!=typeof Symbol&&Symbol.species&&o[Symbol.species]===o&&Object.defineProperty(o,Symbol.species,{value:null,configurable:!0})),o.alloc=function(e,t,n){return u(null,e,t,n)},o.allocUnsafe=function(e){return c(null,e)},o.allocUnsafeSlow=function(e){return c(null,e)},o.isBuffer=function(e){return!(null==e||!e._isBuffer)},o.compare=function(e,t){if(!o.isBuffer(e)||!o.isBuffer(t))throw new TypeError("Arguments must be Buffers");if(e===t)return 0;for(var n=e.length,r=t.length,i=0,a=Math.min(n,r);i0&&(e=this.toString("hex",0,n).match(/.{2}/g).join(" "),this.length>n&&(e+=" ... ")),""},o.prototype.compare=function(e,t,n,r,i){if(!o.isBuffer(e))throw new TypeError("Argument must be a Buffer");if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===r&&(r=0),void 0===i&&(i=this.length),t<0||n>e.length||r<0||i>this.length)throw new RangeError("out of range index");if(r>=i&&t>=n)return 0;if(r>=i)return-1;if(t>=n)return 1;if(t>>>=0,n>>>=0,r>>>=0,i>>>=0,this===e)return 0;for(var a=i-r,s=n-t,u=Math.min(a,s),c=this.slice(r,i),l=e.slice(t,n),p=0;pi)&&(n=i),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");r||(r="utf8");for(var o=!1;;)switch(r){case"hex":return x(this,e,t,n);case"utf8":case"utf-8":return w(this,e,t,n);case"ascii":return k(this,e,t,n);case"latin1":case"binary":return E(this,e,t,n);case"base64":return S(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return C(this,e,t,n);default:if(o)throw new TypeError("Unknown encoding: "+r);r=(""+r).toLowerCase(),o=!0}},o.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var Q=4096;o.prototype.slice=function(e,t){var n=this.length;e=~~e,t=void 0===t?n:~~t,e<0?(e+=n)<0&&(e=0):e>n&&(e=n),t<0?(t+=n)<0&&(t=0):t>n&&(t=n),t0&&(i*=256);)r+=this[e+--t]*i;return r},o.prototype.readUInt8=function(e,t){return t||j(e,1,this.length),this[e]},o.prototype.readUInt16LE=function(e,t){return t||j(e,2,this.length),this[e]|this[e+1]<<8},o.prototype.readUInt16BE=function(e,t){return t||j(e,2,this.length),this[e]<<8|this[e+1]},o.prototype.readUInt32LE=function(e,t){return t||j(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},o.prototype.readUInt32BE=function(e,t){return t||j(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},o.prototype.readIntLE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=this[e],i=1,o=0;++o=i&&(r-=Math.pow(2,8*t)),r},o.prototype.readIntBE=function(e,t,n){e|=0,t|=0,n||j(e,t,this.length);for(var r=t,i=1,o=this[e+--r];r>0&&(i*=256);)o+=this[e+--r]*i;return i*=128,o>=i&&(o-=Math.pow(2,8*t)),o},o.prototype.readInt8=function(e,t){return t||j(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},o.prototype.readInt16LE=function(e,t){t||j(e,2,this.length);var n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},o.prototype.readInt16BE=function(e,t){t||j(e,2,this.length);var n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},o.prototype.readInt32LE=function(e,t){return t||j(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},o.prototype.readInt32BE=function(e,t){return t||j(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},o.prototype.readFloatLE=function(e,t){return t||j(e,4,this.length),$.read(this,e,!0,23,4)},o.prototype.readFloatBE=function(e,t){return t||j(e,4,this.length),$.read(this,e,!1,23,4)},o.prototype.readDoubleLE=function(e,t){return t||j(e,8,this.length),$.read(this,e,!0,52,8)},o.prototype.readDoubleBE=function(e,t){return t||j(e,8,this.length),$.read(this,e,!1,52,8)},o.prototype.writeUIntLE=function(e,t,n,r){if(e=+e,t|=0,n|=0,!r){R(this,e,t,n,Math.pow(2,8*n)-1,0)}var i=1,o=0;for(this[t]=255&e;++o=0&&(o*=256);)this[t+i]=e/o&255;return t+n},o.prototype.writeUInt8=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,1,255,0),o.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),this[t]=255&e,t+1},o.prototype.writeUInt16LE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,2,65535,0),o.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):N(this,e,t,!0),t+2},o.prototype.writeUInt16BE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,2,65535,0),o.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):N(this,e,t,!1),t+2},o.prototype.writeUInt32LE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,4,4294967295,0),o.TYPED_ARRAY_SUPPORT?(this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e):F(this,e,t,!0),t+4},o.prototype.writeUInt32BE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,4,4294967295,0),o.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):F(this,e,t,!1),t+4},o.prototype.writeIntLE=function(e,t,n,r){if(e=+e,t|=0,!r){var i=Math.pow(2,8*n-1);R(this,e,t,n,i-1,-i)}var o=0,a=1,s=0;for(this[t]=255&e;++o>0)-s&255;return t+n},o.prototype.writeIntBE=function(e,t,n,r){if(e=+e,t|=0,!r){var i=Math.pow(2,8*n-1);R(this,e,t,n,i-1,-i)}var o=n-1,a=1,s=0;for(this[t+o]=255&e;--o>=0&&(a*=256);)e<0&&0===s&&0!==this[t+o+1]&&(s=1),this[t+o]=(e/a>>0)-s&255;return t+n},o.prototype.writeInt8=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,1,127,-128),o.TYPED_ARRAY_SUPPORT||(e=Math.floor(e)),e<0&&(e=255+e+1),this[t]=255&e,t+1},o.prototype.writeInt16LE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,2,32767,-32768),o.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8):N(this,e,t,!0),t+2},o.prototype.writeInt16BE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,2,32767,-32768),o.TYPED_ARRAY_SUPPORT?(this[t]=e>>>8,this[t+1]=255&e):N(this,e,t,!1),t+2},o.prototype.writeInt32LE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,4,2147483647,-2147483648),o.TYPED_ARRAY_SUPPORT?(this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24):F(this,e,t,!0),t+4},o.prototype.writeInt32BE=function(e,t,n){return e=+e,t|=0,n||R(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),o.TYPED_ARRAY_SUPPORT?(this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e):F(this,e,t,!1),t+4},o.prototype.writeFloatLE=function(e,t,n){return L(this,e,t,!0,n)},o.prototype.writeFloatBE=function(e,t,n){return L(this,e,t,!1,n)},o.prototype.writeDoubleLE=function(e,t,n){return q(this,e,t,!0,n)},o.prototype.writeDoubleBE=function(e,t,n){return q(this,e,t,!1,n)},o.prototype.copy=function(e,t,n,r){if(n||(n=0),r||0===r||(r=this.length),t>=e.length&&(t=e.length),t||(t=0),r>0&&r=this.length)throw new RangeError("sourceStart out of bounds");if(r<0)throw new RangeError("sourceEnd out of bounds");r>this.length&&(r=this.length),e.length-t=0;--i)e[i+t]=this[i+n];else if(a<1e3||!o.TYPED_ARRAY_SUPPORT)for(i=0;i>>=0,n=void 0===n?this.length:n>>>0,e||(e=0);var a;if("number"==typeof e)for(a=t;a=n?e:e.length+1===n?""+t+e:""+new Array(n-e.length+1).join(t)+e},this.to_hex=function(e){return"string"==typeof e&&(e=e.charCodeAt(0)),e.toString(16)}}).call(this)}).call(t,n(24))},function(e,t){var n=Object;e.exports={create:n.create,getProto:n.getPrototypeOf,isEnum:{}.propertyIsEnumerable,getDesc:n.getOwnPropertyDescriptor,setDesc:n.defineProperty,setDescs:n.defineProperties,getKeys:n.keys,getNames:n.getOwnPropertyNames,getSymbols:n.getOwnPropertySymbols,each:[].forEach}},function(e,t,n){"use strict";var r=n(671),i=Math.max;e.exports=function(e){return i(0,r(e))}},function(e,t,n){function r(e,t){var n=o(e,t);return i(n)?n:void 0}var i=n(794),o=n(834);e.exports=r},function(e,t,n){function r(e){return a(e)?i(e):o(e)}var i=n(376),o=n(796),a=n(113);e.exports=r},function(e,t,n){"use strict"},function(e,t,n){"use strict";var r=n(10),i=(n(7),function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)}),o=function(e,t){var n=this;if(n.instancePool.length){var r=n.instancePool.pop();return n.call(r,e,t),r}return new n(e,t)},a=function(e,t,n){var r=this;if(r.instancePool.length){var i=r.instancePool.pop();return r.call(i,e,t,n),i}return new r(e,t,n)},s=function(e,t,n,r){var i=this;if(i.instancePool.length){var o=i.instancePool.pop();return i.call(o,e,t,n,r),o}return new i(e,t,n,r)},u=function(e){var t=this;e instanceof t||r("25"),e.destructor(),t.instancePool.length1?t-1:0),i=1;i2?n-2:0),o=2;o`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>",u="]",c=new RegExp("^(?:<[A-Za-z][A-Za-z0-9-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*/?>|]|\x3c!----\x3e|\x3c!--(?:-?[^>-])(?:-?[^-])*--\x3e|[<][?].*?[?][>]|]*>|)","i"),l=/[\\&]/,p="[!\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]",f=new RegExp("\\\\"+p+"|"+a,"gi"),h=new RegExp('[&<>"]',"g"),d=new RegExp(a+'|[&<>"]',"gi"),m=function(e){return 92===e.charCodeAt(0)?e.charAt(1):o(e)},v=function(e){return l.test(e)?e.replace(f,m):e},g=function(e){try{return r(i(e))}catch(t){return e}},y=function(e){switch(e){case"&":return"&";case"<":return"<";case">":return">";case'"':return""";default:return e}},_=function(e,t){return h.test(e)?t?e.replace(d,y):e.replace(h,y):e};e.exports={unescapeString:v,normalizeURI:g,escapeXml:_,reHtmlTag:c,OPENTAG:s,CLOSETAG:u,ENTITY:a,ESCAPABLE:p}},function(e,t,n){var r=n(61),i=n(347);e.exports=n(193)?function(e,t,n){return r.setDesc(e,t,i(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t,n){"use strict";var r=n(354)();e.exports=function(e){return e!==r&&null!==e}},function(e,t,n){"use strict";function r(e){return void 0===e||null===e}function i(e){return"object"==typeof e&&null!==e}function o(e){return Array.isArray(e)?e:r(e)?[]:[e]}function a(e,t){var n,r,i,o;if(t)for(o=Object.keys(t),n=0,r=o.length;n1){for(var d=Array(h),m=0;m1){for(var g=Array(v),y=0;y=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t,n){n(580);for(var r=n(20),i=n(57),o=n(71),a=n(17)("toStringTag"),s="CSSRuleList,CSSStyleDeclaration,CSSValueList,ClientRectList,DOMRectList,DOMStringList,DOMTokenList,DataTransferItemList,FileList,HTMLAllCollection,HTMLCollection,HTMLFormElement,HTMLSelectElement,MediaList,MimeTypeArray,NamedNodeMap,NodeList,PaintRequestList,Plugin,PluginArray,SVGLengthList,SVGNumberList,SVGPathSegList,SVGPointList,SVGStringList,SVGTransformList,SourceBufferList,StyleSheetList,TextTrackCueList,TextTrackList,TouchList".split(","),u=0;u0&&void 0!==arguments[0]?arguments[0]:{};return{type:d,payload:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.CLEAR=t.NEW_AUTH_ERR=t.NEW_SPEC_ERR=t.NEW_THROWN_ERR_BATCH=t.NEW_THROWN_ERR=void 0,t.newThrownErr=r,t.newThrownErrBatch=i,t.newSpecErr=o,t.newAuthErr=a,t.clear=s;var u=n(250),c=function(e){return e&&e.__esModule?e:{default:e}}(u),l=t.NEW_THROWN_ERR="err_new_thrown_err",p=t.NEW_THROWN_ERR_BATCH="err_new_thrown_err_batch",f=t.NEW_SPEC_ERR="err_new_spec_err",h=t.NEW_AUTH_ERR="err_new_auth_err",d=t.CLEAR="err_clear"},function(e,t,n){"use strict";t.__esModule=!0,t.default=function(e,t){var n={};for(var r in e)t.indexOf(r)>=0||Object.prototype.hasOwnProperty.call(e,r)&&(n[r]=e[r]);return n}},function(e,t,n){var r=n(54),i=n(328),o=n(326),a=n(29),s=n(129),u=n(187),c={},l={},t=e.exports=function(e,t,n,p,f){var h,d,m,v,g=f?function(){return e}:u(e),y=r(n,p,t?2:1),_=0;if("function"!=typeof g)throw TypeError(e+" is not iterable!");if(o(g)){for(h=s(e.length);h>_;_++)if((v=t?y(a(d=e[_])[0],d[1]):y(e[_]))===c||v===l)return v}else for(m=g.call(e);!(d=m.next()).done;)if((v=i(m,y,d.value,t))===c||v===l)return v};t.BREAK=c,t.RETURN=l},function(e,t){e.exports=!0},function(e,t,n){var r=n(130)("meta"),i=n(27),o=n(56),a=n(35).f,s=0,u=Object.isExtensible||function(){return!0},c=!n(55)(function(){return u(Object.preventExtensions({}))}),l=function(e){a(e,r,{value:{i:"O"+ ++s,w:{}}})},p=function(e,t){if(!i(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!o(e,r)){if(!u(e))return"F";if(!t)return"E";l(e)}return e[r].i},f=function(e,t){if(!o(e,r)){if(!u(e))return!0;if(!t)return!1;l(e)}return e[r].w},h=function(e){return c&&d.NEED&&u(e)&&!o(e,r)&&l(e),e},d=e.exports={KEY:r,NEED:!1,fastKey:p,getWeak:f,onFreeze:h}},function(e,t){t.f={}.propertyIsEnumerable},function(e,t,n){var r=n(183),i=Math.min;e.exports=function(e){return e>0?i(r(e),9007199254740991):0}},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){var r=n(30),i=n(75),o=n(349)("src"),a=Function.toString,s=(""+a).split("toString");n(99).inspectSource=function(e){return a.call(e)},(e.exports=function(e,t,n,a){"function"==typeof n&&(n.hasOwnProperty(o)||i(n,o,e[t]?""+e[t]:s.join(String(t))),n.hasOwnProperty("name")||i(n,"name",t)),e===r?e[t]=n:(a||delete e[t],i(e,t,n))})(Function.prototype,"toString",function(){return"function"==typeof this&&this[o]||a.call(this)})},function(e,t,n){"use strict";var r,i=n(355),o=n(358),a=n(675),s=n(680);r=e.exports=function(e,t){var n,r,a,u,c;return arguments.length<2||"string"!=typeof e?(u=t,t=e,e=null):u=arguments[2],null==e?(n=a=!0,r=!1):(n=s.call(e,"c"),r=s.call(e,"e"),a=s.call(e,"w")),c={value:t,configurable:n,enumerable:r,writable:a},u?i(o(u),c):c},r.gs=function(e,t,n){var r,u,c,l;return"string"!=typeof e?(c=n,n=t,t=e,e=null):c=arguments[3],null==t?t=void 0:a(t)?null==n?n=void 0:a(n)||(c=n,n=void 0):(c=t,t=n=void 0),null==e?(r=!0,u=!1):(r=s.call(e,"c"),u=s.call(e,"e")),l={get:t,set:n,configurable:r,enumerable:u},c?i(o(c),l):l}},function(e,t,n){"use strict";e.exports=n(672)("forEach")},function(e,t){function n(){this._events=this._events||{},this._maxListeners=this._maxListeners||void 0}function r(e){return"function"==typeof e}function i(e){return"number"==typeof e}function o(e){return"object"==typeof e&&null!==e}function a(e){return void 0===e}e.exports=n,n.EventEmitter=n,n.prototype._events=void 0,n.prototype._maxListeners=void 0,n.defaultMaxListeners=10,n.prototype.setMaxListeners=function(e){if(!i(e)||e<0||isNaN(e))throw TypeError("n must be a positive number");return this._maxListeners=e,this},n.prototype.emit=function(e){var t,n,i,s,u,c;if(this._events||(this._events={}),"error"===e&&(!this._events.error||o(this._events.error)&&!this._events.error.length)){if((t=arguments[1])instanceof Error)throw t;var l=new Error('Uncaught, unspecified "error" event. ('+t+")");throw l.context=t,l}if(n=this._events[e],a(n))return!1;if(r(n))switch(arguments.length){case 1:n.call(this);break;case 2:n.call(this,arguments[1]);break;case 3:n.call(this,arguments[1],arguments[2]);break;default:s=Array.prototype.slice.call(arguments,1),n.apply(this,s)}else if(o(n))for(s=Array.prototype.slice.call(arguments,1),c=n.slice(),i=c.length,u=0;u0&&this._events[e].length>i&&(this._events[e].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[e].length),"function"==typeof console.trace&&console.trace()),this},n.prototype.on=n.prototype.addListener,n.prototype.once=function(e,t){function n(){this.removeListener(e,n),i||(i=!0,t.apply(this,arguments))}if(!r(t))throw TypeError("listener must be a function");var i=!1;return n.listener=t,this.on(e,n),this},n.prototype.removeListener=function(e,t){var n,i,a,s;if(!r(t))throw TypeError("listener must be a function");if(!this._events||!this._events[e])return this;if(n=this._events[e],a=n.length,i=-1,n===t||r(n.listener)&&n.listener===t)delete this._events[e],this._events.removeListener&&this.emit("removeListener",e,t);else if(o(n)){for(s=a;s-- >0;)if(n[s]===t||n[s].listener&&n[s].listener===t){i=s;break}if(i<0)return this;1===n.length?(n.length=0,delete this._events[e]):n.splice(i,1),this._events.removeListener&&this.emit("removeListener",e,t)}return this},n.prototype.removeAllListeners=function(e){var t,n;if(!this._events)return this;if(!this._events.removeListener)return 0===arguments.length?this._events={}:this._events[e]&&delete this._events[e],this;if(0===arguments.length){for(t in this._events)"removeListener"!==t&&this.removeAllListeners(t);return this.removeAllListeners("removeListener"),this._events={},this}if(n=this._events[e],r(n))this.removeListener(e,n);else if(n)for(;n.length;)this.removeListener(e,n[n.length-1]);return delete this._events[e],this},n.prototype.listeners=function(e){return this._events&&this._events[e]?r(this._events[e])?[this._events[e]]:this._events[e].slice():[]},n.prototype.listenerCount=function(e){if(this._events){var t=this._events[e];if(r(t))return 1;if(t)return t.length}return 0},n.listenerCount=function(e,t){return e.listenerCount(t)}},function(e,t,n){"use strict";function r(e,t){return e===t?0!==e||0!==t||1/e==1/t:e!==e&&t!==t}function i(e,t){if(r(e,t))return!0;if("object"!=typeof e||null===e||"object"!=typeof t||null===t)return!1;var n=Object.keys(e),i=Object.keys(t);if(n.length!==i.length)return!1;for(var a=0;a]/;e.exports=i},function(e,t,n){"use strict";var r,i=n(21),o=n(226),a=/^[ \r\n\t\f]/,s=/<(!--|link|noscript|meta|script|style)[ \r\n\t\f\/>]/,u=n(234),c=u(function(e,t){if(e.namespaceURI!==o.svg||"innerHTML"in e)e.innerHTML=t;else{r=r||document.createElement("div"),r.innerHTML=""+t+"";for(var n=r.firstChild;n.firstChild;)e.appendChild(n.firstChild)}});if(i.canUseDOM){var l=document.createElement("div");l.innerHTML=" ",""===l.innerHTML&&(c=function(e,t){if(e.parentNode&&e.parentNode.replaceChild(e,e),a.test(t)||"<"===t[0]&&s.test(t)){e.innerHTML=String.fromCharCode(65279)+t;var n=e.firstChild;1===n.data.length?e.removeChild(n):n.deleteData(0,1)}else e.innerHTML=t}),l=null}e.exports=c},function(e,t,n){"use strict";e.exports=function(e,t){var n,r,i,o=-1,a=e.posMax,s=e.pos,u=e.isInLabel;if(e.isInLabel)return-1;if(e.labelUnmatchedScopes)return e.labelUnmatchedScopes--,-1;for(e.pos=t+1,e.isInLabel=!0,n=1;e.pos1&&void 0!==arguments[1])||arguments[1];return e=(0,s.normalizeArray)(e),{type:p,payload:{thing:e,shown:t}}}function a(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return e=(0,s.normalizeArray)(e),{type:l,payload:{thing:e,mode:t}}}Object.defineProperty(t,"__esModule",{value:!0}),t.SHOW=t.UPDATE_MODE=t.UPDATE_FILTER=t.UPDATE_LAYOUT=void 0,t.updateLayout=r,t.updateFilter=i,t.show=o,t.changeMode=a;var s=n(11),u=t.UPDATE_LAYOUT="layout_update_layout",c=t.UPDATE_FILTER="layout_update_filter",l=t.UPDATE_MODE="layout_update_mode",p=t.SHOW="layout_show"},function(e,t,n){"use strict";function r(e){return{type:s,payload:e}}function i(e){var t=e.value,n=e.pathMethod;return{type:u,payload:{value:t,pathMethod:n}}}function o(e){var t=e.value,n=e.pathMethod;return{type:c,payload:{value:t,pathMethod:n}}}function a(e){var t=e.server,n=e.key,r=e.val;return{type:l,payload:{server:t,key:n,val:r}}}Object.defineProperty(t,"__esModule",{value:!0}),t.setSelectedServer=r,t.setRequestBodyValue=i,t.setRequestContentType=o,t.setServerVariableValue=a;var s=t.UPDATE_SELECTED_SERVER="oas3_set_servers",u=t.UPDATE_REQUEST_BODY_VALUE="oas3_set_request_body_value",c=t.UPDATE_REQUEST_CONTENT_TYPE="oas3_set_request_content_type",l=t.UPDATE_SERVER_VARIABLE_VALUE="oas3_set_server_variable_value"},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e,t){var n=h(e,t);if(n)return(0,s.default)(n,{declaration:!0,indent:"\t"})}Object.defineProperty(t,"__esModule",{value:!0}),t.memoizedSampleFromSchema=t.memoizedCreateXMLExample=t.sampleXmlFromSchema=t.inferSchema=t.sampleFromSchema=void 0,t.createXMLExample=i;var o=n(11),a=n(1236),s=r(a),u=n(909),c=r(u),l={string:function(){return"string"},string_email:function(){return"user@example.com"},"string_date-time":function(){return(new Date).toISOString()},number:function(){return 0},number_float:function(){return 0},integer:function(){return 0},boolean:function(e){return"boolean"!=typeof e.default||e.default}},p=function(e){e=(0,o.objectify)(e);var t=e,n=t.type,r=t.format,i=l[n+"_"+r]||l[n];return(0,o.isFunc)(i)?i(e):"Unknown Type: "+e.type},f=t.sampleFromSchema=function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=(0,o.objectify)(t),i=r.type,a=r.example,s=r.properties,u=r.additionalProperties,c=r.items,l=n.includeReadOnly,f=n.includeWriteOnly;if(void 0!==a)return a;if(!i)if(s)i="object";else{if(!c)return;i="array"}if("object"===i){var h=(0,o.objectify)(s),d={};for(var m in h)h[m].readOnly&&!l||h[m].writeOnly&&!f||(d[m]=e(h[m],n));if(!0===u)d.additionalProp1={};else if(u)for(var v=(0,o.objectify)(u),g=e(v,n),y=1;y<4;y++)d["additionalProp"+y]=g;return d}return"array"===i?[e(c,n)]:t.enum?t.default?t.default:(0,o.normalizeArray)(t.enum)[0]:"file"!==i?p(t):void 0},h=(t.inferSchema=function(e){return e.schema&&(e=e.schema),e.properties&&(e.type="object"),e},t.sampleXmlFromSchema=function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r=(0,o.objectify)(t),i=r.type,a=r.properties,s=r.additionalProperties,u=r.items,c=r.example,l=n.includeReadOnly,f=n.includeWriteOnly,h=r.default,d={},m={},v=t.xml,g=v.name,y=v.prefix,_=v.namespace,b=r.enum,x=void 0,w=void 0;if(!i)if(a||s)i="object";else{if(!u)return;i="array"}if(g=g||"notagname",x=(y?y+":":"")+g,_){m[y?"xmlns:"+y:"xmlns"]=_}if("array"===i&&u){if(u.xml=u.xml||v||{},u.xml.name=u.xml.name||v.name,v.wrapped)return d[x]=[],Array.isArray(c)?c.forEach(function(t){u.example=t,d[x].push(e(u,n))}):Array.isArray(h)?h.forEach(function(t){u.default=t,d[x].push(e(u,n))}):d[x]=[e(u,n)],m&&d[x].push({_attr:m}),d;var k=[];return Array.isArray(c)?(c.forEach(function(t){u.example=t,k.push(e(u,n))}),k):Array.isArray(h)?(h.forEach(function(t){u.default=t,k.push(e(u,n))}),k):e(u,n)}if("object"===i){var E=(0,o.objectify)(a);d[x]=[],c=c||{};for(var S in E)if((!E[S].readOnly||l)&&(!E[S].writeOnly||f))if(E[S].xml=E[S].xml||{},E[S].xml.attribute){var C=Array.isArray(E[S].enum)&&E[S].enum[0],A=E[S].example,D=E[S].default;m[E[S].xml.name||S]=void 0!==A&&A||void 0!==c[S]&&c[S]||void 0!==D&&D||C||p(E[S])}else{E[S].xml.name=E[S].xml.name||S,E[S].example=void 0!==E[S].example?E[S].example:c[S];var M=e(E[S]);Array.isArray(M)?d[x]=d[x].concat(M):d[x].push(M)}return!0===s?d[x].push({additionalProp:"Anything can be here"}):s&&d[x].push({additionalProp:p(s)}),m&&d[x].push({_attr:m}),d}return w=void 0!==c?c:void 0!==h?h:Array.isArray(b)?b[0]:p(t),d[x]=m?[{_attr:m},w]:w,d});t.memoizedCreateXMLExample=(0,c.default)(i),t.memoizedSampleFromSchema=(0,c.default)(f)},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function i(e){return e instanceof Error?{type:T,error:!0,payload:e}:"string"==typeof e?{type:T,payload:e.replace(/\t/g," ")||""}:{type:T,payload:""}}function o(e){return{type:V,payload:e}}function a(e){return{type:P,payload:e}}function s(e){if(!e||"object"!==(void 0===e?"undefined":(0,k.default)(e)))throw new Error("updateJson must only accept a simple JSON object");return{type:I,payload:e}}function u(e,t,n,r){return{type:j,payload:{path:e,value:n,paramName:t,isXml:r}}}function c(e){return{type:R,payload:{pathMethod:e}}}function l(e){return{type:U,payload:{pathMethod:e}}}function p(e,t){return{type:W,payload:{path:e,value:t,key:"consumes_value"}}}function f(e,t){return{type:W,payload:{path:e,value:t,key:"produces_value"}}}function h(e,t){return{type:q,payload:{path:e,method:t}}}function d(e,t){return{type:z,payload:{path:e,method:t}}}function m(e,t,n){return{type:K,payload:{scheme:e,path:t,method:n}}}Object.defineProperty(t,"__esModule",{value:!0}),t.execute=t.executeRequest=t.logRequest=t.setMutatedRequest=t.setRequest=t.setResponse=t.formatIntoYaml=t.resolveSpec=t.parseToJson=t.SET_SCHEME=t.UPDATE_RESOLVED=t.UPDATE_OPERATION_VALUE=t.ClEAR_VALIDATE_PARAMS=t.CLEAR_REQUEST=t.CLEAR_RESPONSE=t.LOG_REQUEST=t.SET_MUTATED_REQUEST=t.SET_REQUEST=t.SET_RESPONSE=t.VALIDATE_PARAMS=t.UPDATE_PARAM=t.UPDATE_JSON=t.UPDATE_URL=t.UPDATE_SPEC=void 0;var v=n(18),g=r(v),y=n(124),_=r(y),b=n(34),x=r(b),w=n(43),k=r(w);t.updateSpec=i,t.updateResolved=o,t.updateUrl=a,t.updateJsonSpec=s,t.changeParam=u,t.validateParams=c,t.clearValidateParams=l,t.changeConsumesValue=p,t.changeProducesValue=f,t.clearResponse=h,t.clearRequest=d,t.setScheme=m;var E=n(370),S=r(E),C=n(1226),A=r(C),D=n(250),M=r(D),O=n(11),T=t.UPDATE_SPEC="spec_update_spec",P=t.UPDATE_URL="spec_update_url",I=t.UPDATE_JSON="spec_update_json",j=t.UPDATE_PARAM="spec_update_param",R=t.VALIDATE_PARAMS="spec_validate_param",N=t.SET_RESPONSE="spec_set_response",F=t.SET_REQUEST="spec_set_request",B=t.SET_MUTATED_REQUEST="spec_set_mutated_request",L=t.LOG_REQUEST="spec_log_request",q=t.CLEAR_RESPONSE="spec_clear_response",z=t.CLEAR_REQUEST="spec_clear_request",U=t.ClEAR_VALIDATE_PARAMS="spec_clear_validate_param",W=t.UPDATE_OPERATION_VALUE="spec_update_operation_value",V=t.UPDATE_RESOLVED="spec_update_resolved",K=t.SET_SCHEME="set_scheme",H=(t.parseToJson=function(e){return function(t){var n=t.specActions,r=t.specSelectors,i=t.errActions,o=r.specStr,a=null;try{e=e||o(),i.clear({source:"parser"}),a=S.default.safeLoad(e)}catch(e){return console.error(e),i.newSpecErr({source:"parser",level:"error",message:e.reason,line:e.mark&&e.mark.line?e.mark.line+1:void 0})}return n.updateJsonSpec(a)}},t.resolveSpec=function(e,t){return function(n){var r=n.specActions,i=n.specSelectors,o=n.errActions,a=n.fn,s=a.fetch,u=a.resolve,c=a.AST,l=n.getConfigs,p=l(),f=p.modelPropertyMacro,h=p.parameterMacro;void 0===e&&(e=i.specJson()),void 0===t&&(t=i.url());var d=c.getLineNumberForPath,m=i.specStr();return u({fetch:s,spec:e,baseDoc:t,modelPropertyMacro:f,parameterMacro:h}).then(function(e){var t=e.spec,n=e.errors;if(o.clear({type:"thrown"}),n.length>0){var i=n.map(function(e){return console.error(e),e.line=e.fullPath?d(m,e.fullPath):null,e.path=e.fullPath?e.fullPath.join("."):null,e.level="error",e.type="thrown",e.source="resolver",Object.defineProperty(e,"message",{enumerable:!0,value:e.message}),e});o.newThrownErrBatch(i)}return r.updateResolved(t)})}},t.formatIntoYaml=function(){return function(e){var t=e.specActions,n=e.specSelectors,r=n.specStr,i=t.updateSpec;try{var o=S.default.safeDump(S.default.safeLoad(r()),{indent:2});i(o)}catch(e){i(e)}}},t.setResponse=function(e,t,n){return{payload:{path:e,method:t,res:n},type:N}},t.setRequest=function(e,t,n){return{payload:{path:e,method:t,req:n},type:F}},t.setMutatedRequest=function(e,t,n){return{payload:{path:e,method:t,req:n},type:B}},t.logRequest=function(e){return{payload:e,type:L}},t.executeRequest=function(e){return function(t){var n=t.fn,r=t.specActions,i=t.specSelectors,o=t.getConfigs,a=t.oas3Selectors,s=e.pathName,u=e.method,c=e.operation,l=o(),p=l.requestInterceptor,f=l.responseInterceptor,h=c.toJS();if(e.contextUrl=(0,A.default)(i.url()).toString(),h&&h.operationId?e.operationId=h.operationId:h&&s&&u&&(e.operationId=n.opId(h,s,u)),i.isOAS3()){e.server=a.selectedServer(),e.serverVariables=a.serverVariables(e.server).toJS(),e.requestContentType=a.requestContentType(s,u);var d=a.requestBodyValue(s,u);(0,O.isJSONObject)(d)?e.requestBody=JSON.parse(d):e.requestBody=d}var m=(0,x.default)({},e);m=n.buildRequest(m),r.setRequest(e.pathName,e.method,m);var v=function(t){var n=p.apply(this,[t]),i=(0,x.default)({},n);return r.setMutatedRequest(e.pathName,e.method,i),n};e.requestInterceptor=v,e.responseInterceptor=f;var g=Date.now();return n.execute(e).then(function(t){t.duration=Date.now()-g,r.setResponse(e.pathName,e.method,t)}).catch(function(t){return r.setResponse(e.pathName,e.method,{error:!0,err:(0,M.default)(t)})})}},function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=e.path,n=e.method,r=(0,_.default)(e,["path","method"]);return function(e){var i=e.fn.fetch,o=e.specSelectors,a=e.specActions,s=o.spec().toJS(),u=o.operationScheme(t,n),c=o.contentTypeValues([t,n]).toJS(),l=c.requestContentType,p=c.responseContentType,f=/xml/i.test(l),h=o.parameterValues([t,n],f).toJS();return a.executeRequest((0,g.default)({fetch:i,spec:s,pathName:t,method:n,parameters:h,requestContentType:l,scheme:u,responseContentType:p},r))}});t.execute=H},function(e,t,n){"use strict";var r=n(11),i=n(1247);i.keys().forEach(function(t){if("./index.js"!==t){var n=i(t);e.exports[(0,r.pascalCaseFilename)(t)]=n.default?n.default:n}})},function(e,t){e.exports=function(e,t,n,r){if(!(e instanceof t)||void 0!==r&&r in e)throw TypeError(n+": incorrect invocation!");return e}},function(e,t,n){var r=n(54),i=n(175),o=n(73),a=n(129),s=n(560);e.exports=function(e,t){var n=1==e,u=2==e,c=3==e,l=4==e,p=6==e,f=5==e||p,h=t||s;return function(t,s,d){for(var m,v,g=o(t),y=i(g),_=r(s,d,3),b=a(y.length),x=0,w=n?h(t,b):u?h(t,0):void 0;b>x;x++)if((f||x in y)&&(m=y[x],v=_(m,x,g),e))if(n)w[x]=v;else if(v)switch(e){case 3:return!0;case 5:return m;case 6:return x;case 2:w.push(m)}else if(l)return!1;return p?-1:c||l?l:w}}},function(e,t,n){var r=n(92),i=n(17)("toStringTag"),o="Arguments"==r(function(){return arguments}()),a=function(e,t){try{return e[t]}catch(e){}};e.exports=function(e){var t,n,s;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(n=a(t=Object(e),i))?n:o?r(t):"Object"==(s=r(t))&&"function"==typeof t.callee?"Arguments":s}},function(e,t){e.exports=function(e){if(void 0==e)throw TypeError("Can't call method on "+e);return e}},function(e,t,n){var r=n(27),i=n(20).document,o=r(i)&&r(i.createElement);e.exports=function(e){return o?i.createElement(e):{}}},function(e,t){e.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(e,t,n){var r=n(92);e.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==r(e)?e.split(""):Object(e)}},function(e,t,n){"use strict";function r(e){var t,n;this.promise=new e(function(e,r){if(void 0!==t||void 0!==n)throw TypeError("Bad Promise constructor");t=e,n=r}),this.resolve=i(t),this.reject=i(n)}var i=n(91);e.exports.f=function(e){return new r(e)}},function(e,t,n){var r=n(29),i=n(569),o=n(174),a=n(181)("IE_PROTO"),s=function(){},u=function(){var e,t=n(173)("iframe"),r=o.length;for(t.style.display="none",n(324).appendChild(t),t.src="javascript:",e=t.contentWindow.document,e.open(),e.write("