diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c7ed3b3b..1251d6065 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,6 +32,7 @@ jobs: - forest_admin_datasource_rpc - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake + - forest_admin_datasource_mambu_payments steps: - name: Checkout @@ -74,6 +75,7 @@ jobs: - forest_admin_datasource_rpc - forest_admin_datasource_zendesk - forest_admin_datasource_snowflake + - forest_admin_datasource_mambu_payments services: mongodb: image: mongo:latest @@ -141,7 +143,7 @@ jobs: with: verbose: true oidc: true - files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json + files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_snowflake/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mambu_payments/coverage.json deploy: name: Release package diff --git a/.releaserc.js b/.releaserc.js index 1404dfe67..6ba231515 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -29,7 +29,8 @@ module.exports = { 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb; '+ - 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb; ', + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb; '+ + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb; ', successCmd: '( cd packages/forest_admin_agent && gem build && gem push forest_admin_agent-*.gem );' + '( cd packages/forest_admin_datasource_active_record && gem build && gem push forest_admin_datasource_active_record-*.gem );' + @@ -41,7 +42,8 @@ module.exports = { '( cd packages/forest_admin_rpc_agent && gem build && gem push forest_admin_rpc_agent-*.gem );' + '( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' + '( cd packages/forest_admin_datasource_zendesk && gem build && gem push forest_admin_datasource_zendesk-*.gem );' + - '( cd packages/forest_admin_datasource_snowflake && gem build && gem push forest_admin_datasource_snowflake-*.gem );' , + '( cd packages/forest_admin_datasource_snowflake && gem build && gem push forest_admin_datasource_snowflake-*.gem );' + + '( cd packages/forest_admin_datasource_mambu_payments && gem build && gem push forest_admin_datasource_mambu_payments-*.gem );' , }, ], [ @@ -62,6 +64,7 @@ module.exports = { 'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb', 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb', 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb', + 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb', 'package.json' ], }, diff --git a/.rubocop.yml b/.rubocop.yml index dcb1f6894..ac26801c2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -129,6 +129,7 @@ Style/MutableConstant: - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/version.rb' - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb' + - 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb' # Offense count: 38 # This cop supports safe autocorrection (--autocorrect). @@ -211,6 +212,7 @@ Style/StringLiterals: - 'packages/forest_admin_datasource_active_record/spec/spec_helper.rb' - 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb' - 'packages/forest_admin_datasource_snowflake/lib/forest_admin_datasource_snowflake/version.rb' + - 'packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). @@ -282,6 +284,7 @@ Metrics/ModuleLength: - 'packages/forest_admin_datasource_mongoid/spec/**/*' - 'packages/forest_admin_datasource_customizer/spec/**/*' - 'packages/forest_admin_datasource_zendesk/spec/**/*' + - 'packages/forest_admin_datasource_mambu_payments/spec/**/*' - 'packages/forest_admin_rails/spec/**/*' - 'packages/forest_admin_rpc_agent/spec/**/*' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb' diff --git a/packages/forest_admin_datasource_mambu_payments/.rspec b/packages/forest_admin_datasource_mambu_payments/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/packages/forest_admin_datasource_mambu_payments/Gemfile b/packages/forest_admin_datasource_mambu_payments/Gemfile new file mode 100644 index 000000000..c229ff1d5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/Gemfile @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +gemspec + +gem 'forest_admin_datasource_customizer' +gem 'forest_admin_datasource_toolkit' +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_mambu_payments/Gemfile-test b/packages/forest_admin_datasource_mambu_payments/Gemfile-test new file mode 100644 index 000000000..d7b873663 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/Gemfile-test @@ -0,0 +1,19 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in forest_admin_datasource_mambu_payments.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'rubocop', '1.86.1' +gem 'rubocop-performance', '1.26.1' +gem 'rubocop-rspec', '3.9.0' + +group :development, :test do + gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer' + gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_mambu_payments/Rakefile b/packages/forest_admin_datasource_mambu_payments/Rakefile new file mode 100644 index 000000000..4c774a2bf --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec b/packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec new file mode 100644 index 000000000..c018c1fb5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec @@ -0,0 +1,37 @@ +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) + +require_relative 'lib/forest_admin_datasource_mambu_payments/version' + +Gem::Specification.new do |spec| + spec.name = 'forest_admin_datasource_mambu_payments' + spec.version = ForestAdminDatasourceMambuPayments::VERSION + spec.authors = ['Forest Admin'] + spec.email = ['contact@forestadmin.com'] + spec.homepage = 'https://www.forestadmin.com' + spec.summary = 'Mambu Payments (Numeral) datasource for Forest Admin Ruby agent.' + spec.description = 'Surface Mambu Payments connected accounts, payment orders, ' \ + 'transactions and balances as Forest Admin collections.' + spec.license = 'GPL-3.0' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby' + spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'true' + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_dependency 'activesupport', '>= 6.1' + spec.add_dependency 'faraday', '~> 2.0' + spec.add_dependency 'faraday-retry', '~> 2.0' + spec.add_dependency 'zeitwerk', '~> 2.3' +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments.rb new file mode 100644 index 000000000..29ff15784 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments.rb @@ -0,0 +1,44 @@ +require_relative 'forest_admin_datasource_mambu_payments/version' +require 'logger' +require 'zeitwerk' +require 'faraday' +require 'faraday/retry' +require 'forest_admin_datasource_toolkit' + +loader = Zeitwerk::Loader.for_gem +loader.setup + +module ForestAdminDatasourceMambuPayments + class Error < StandardError; end + class ConfigurationError < Error; end + class UnsupportedOperatorError < Error; end + + # Raised when a Numeral API call fails. Carries the HTTP status and the + # (parsed) response body so callers — smart actions in particular — can + # surface the API's own validation message instead of a generic string. + class APIError < Error + attr_reader :status, :body + + def initialize(message, status: nil, body: nil) + super(message) + @status = status + @body = body + end + end + + class << self + attr_writer :logger + + def logger + @logger ||= default_logger + end + + private + + def default_logger + return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + + Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_mambu_payments' } + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client.rb new file mode 100644 index 000000000..fa56391bc --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client.rb @@ -0,0 +1,166 @@ +module ForestAdminDatasourceMambuPayments + # rubocop:disable Metrics/ClassLength + class Client + include Reads + include Writes + + MAX_PER_PAGE = 100 + + def initialize(configuration) + @configuration = configuration + end + + private + + def list_resource(path, params = {}) + must_succeed("list(#{path})") do + body = connection.get(path, normalize_params(params)).body + extract_records(body, path) + end + end + + def get_resource(path, id) + extract_record(connection.get("#{path}/#{id}").body) + rescue Faraday::ResourceNotFound + nil + rescue Faraday::Error => e + raise api_error("get(#{path}/#{id})", e) + rescue StandardError => e + raise APIError, "Mambu Payments API call failed: get(#{path}/#{id}): #{e.class}: #{e.message}" + end + + def post_resource(path, attributes) + must_succeed("create(#{path})") do + extract_record(connection.post(path, attributes).body) + end + end + + def patch_resource(path, id, attributes) + must_succeed("update(#{path}/#{id})") do + extract_record(connection.patch("#{path}/#{id}", attributes).body) + end + end + + # POST .../:id/:action — Numeral exposes side-effect endpoints (approve, + # cancel, verify) as sub-paths returning the updated resource. + def post_action_resource(path, id, action, attributes = {}) + must_succeed("#{action}(#{path}/#{id})") do + extract_record(connection.post("#{path}/#{id}/#{action}", attributes).body) + end + end + + # Numeral list responses are typically wrapped (e.g. { "data": [...] } or + # { "connected_accounts": [...] }) but we accept a raw array too. Falls back + # to the first array-valued field so we don't silently coerce a wrapper hash + # into an array of [key, value] pairs. + def extract_records(body, path) + return body if body.is_a?(Array) + return [] unless body.is_a?(Hash) + + wrapped = body['data'] || body[path] || body['records'] || body['items'] + wrapped.is_a?(Array) ? wrapped : fallback_records(body, path) + end + + # Last resort when the wrapper key is unknown: return the first array-valued + # field, logging that we guessed so an unexpected envelope is visible. + def fallback_records(body, path) + fallback = body.values.find { |v| v.is_a?(Array) } + return [] unless fallback + + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] list(#{path}) used wrapper-key fallback; " \ + "body keys=#{body.keys.inspect}" + ) + fallback + end + + def extract_record(body) + return nil if body.nil? + return body['data'] if body.is_a?(Hash) && body['data'].is_a?(Hash) + + body + end + + def delete_resource(path, id) + must_succeed("delete(#{path}/#{id})") do + connection.delete("#{path}/#{id}") + true + end + end + + def normalize_params(params) + params.compact.transform_values { |v| v.is_a?(Array) ? v.join(',') : v } + end + + def must_succeed(operation) + yield + rescue Faraday::Error => e + raise api_error(operation, e) + rescue StandardError => e + raise APIError, "Mambu Payments API call failed: #{operation}: #{e.class}: #{e.message}" + end + + # Builds an APIError that preserves the HTTP status and the API's own error + # body. Numeral returns structured validation errors (e.g. on a 422), which + # smart actions surface to the operator instead of a generic failure string. + def api_error(operation, error) + response = error.respond_to?(:response) ? error.response : nil + status = response.is_a?(Hash) ? response[:status] : nil + body = response.is_a?(Hash) ? response[:body] : nil + detail = error_detail(status, body) || "#{error.class}: #{error.message}" + APIError.new("Mambu Payments API call failed: #{operation}: #{detail}", status: status, body: parse_body(body)) + end + + def error_detail(status, body) + return nil unless status + + ["HTTP #{status}", error_message(parse_body(body))].compact.join(' ').strip + end + + # Pulls the human-readable message out of the common Numeral error shapes + # ({ "error": { "message": ... } }, { "errors": [...] }, { "message": ... }). + def error_message(parsed) + return parsed.to_s[0, 500] unless parsed.is_a?(Hash) + + message = parsed.dig('error', 'message') || parsed['message'] || parsed['detail'] || join_errors(parsed['errors']) + (message.to_s.empty? ? parsed.to_json : message)[0, 500] + end + + def join_errors(errors) + Array(errors).filter_map { |e| e.is_a?(Hash) ? (e['message'] || e['detail']) : e }.join('; ') + end + + def parse_body(body) + return body unless body.is_a?(String) && !body.empty? + + JSON.parse(body) + rescue JSON::ParserError + body + end + + def best_effort(operation, default:) + yield + rescue StandardError => e + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] #{operation} failed; degrading: #{e.class}: #{e.message}" + ) + default + end + + def connection + @connection ||= Faraday.new(url: @configuration.url) do |f| + f.request :json + f.request :retry, max: 3, interval: 0.2, backoff_factor: 2, + retry_statuses: [429, 502, 503, 504] + f.response :json + f.response :raise_error + f.headers['x-api-key'] = @configuration.api_key + f.headers['Accept'] = 'application/json' + f.headers['User-Agent'] = "forest_admin_datasource_mambu_payments/#{VERSION}" + f.options.open_timeout = @configuration.open_timeout + f.options.timeout = @configuration.timeout + end + end + end + # rubocop:enable Metrics/ClassLength +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/reads.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/reads.rb new file mode 100644 index 000000000..535cb4d19 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/reads.rb @@ -0,0 +1,66 @@ +module ForestAdminDatasourceMambuPayments + class Client + module Reads + def list_connected_accounts(**params) = list_resource('connected_accounts', params) + def find_connected_account(id) = get_resource('connected_accounts', id) + + def list_payment_orders(**params) = list_resource('payment_orders', params) + def find_payment_order(id) = get_resource('payment_orders', id) + + def list_transactions(**params) = list_resource('transactions', params) + def find_transaction(id) = get_resource('transactions', id) + + def list_balances(**params) = list_resource('balances', params) + def find_balance(id) = get_resource('balances', id) + + def list_account_holders(**params) = list_resource('account_holders', params) + def find_account_holder(id) = get_resource('account_holders', id) + + def list_external_accounts(**params) = list_resource('external_accounts', params) + def find_external_account(id) = get_resource('external_accounts', id) + + def list_internal_accounts(**params) = list_resource('internal_accounts', params) + def find_internal_account(id) = get_resource('internal_accounts', id) + + def list_incoming_payments(**params) = list_resource('incoming_payments', params) + def find_incoming_payment(id) = get_resource('incoming_payments', id) + + def list_direct_debit_mandates(**params) = list_resource('direct_debit_mandates', params) + def find_direct_debit_mandate(id) = get_resource('direct_debit_mandates', id) + + def list_expected_payments(**params) = list_resource('expected_payments', params) + def find_expected_payment(id) = get_resource('expected_payments', id) + + def list_events(**params) = list_resource('events', params) + def find_event(id) = get_resource('events', id) + + def list_files(**params) = list_resource('files', params) + def find_file(id) = get_resource('files', id) + + def list_returns(**params) = list_resource('returns', params) + def find_return(id) = get_resource('returns', id) + + # Claims are arrived-from-the-network resources (created via the sandbox + # simulator or by the counterparty bank). No POST/PATCH/DELETE here: + # accept/reject are lifecycle actions and would belong in a plugin. + def list_claims(**params) = list_resource('claims', params) + def find_claim(id) = get_resource('claims', id) + + def list_reconciliations(**params) = list_resource('reconciliations', params) + def find_reconciliation(id) = get_resource('reconciliations', id) + + # Payment captures are emitted by PSPs (or registered manually via API + # to reconcile reporting files). create/update/cancel exist on the + # Numeral API but are lifecycle operations deferred to a future plugin. + def list_payment_captures(**params) = list_resource('payment_captures', params) + def find_payment_capture(id) = get_resource('payment_captures', id) + + # Payee verification requests are emitted by Numeral when an outgoing + # verification is sent (via the TriggerPayeeVerification plugin) or + # when an incoming verification arrives from the network. send / + # simulate are exposed as smart actions, not collection writes. + def list_payee_verification_requests(**params) = list_resource('payee_verification_requests', params) + def find_payee_verification_request(id) = get_resource('payee_verification_requests', id) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/writes.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/writes.rb new file mode 100644 index 000000000..896f4c7b5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/writes.rb @@ -0,0 +1,42 @@ +module ForestAdminDatasourceMambuPayments + class Client + module Writes + def create_payment_order(attrs) = post_resource('payment_orders', attrs) + def update_payment_order(id, attrs) = patch_resource('payment_orders', id, attrs) + def delete_payment_order(id) = delete_resource('payment_orders', id) + def approve_payment_order(id, attrs = {}) = post_action_resource('payment_orders', id, 'approve', attrs) + def cancel_payment_order(id, attrs = {}) = post_action_resource('payment_orders', id, 'cancel', attrs) + + def create_account_holder(attrs) = post_resource('account_holders', attrs) + def update_account_holder(id, attrs) = patch_resource('account_holders', id, attrs) + def delete_account_holder(id) = delete_resource('account_holders', id) + + def create_external_account(attrs) = post_resource('external_accounts', attrs) + def update_external_account(id, attrs) = patch_resource('external_accounts', id, attrs) + def delete_external_account(id) = delete_resource('external_accounts', id) + def verify_external_account(id, attrs = {}) = post_action_resource('external_accounts', id, 'verify', attrs) + + def create_internal_account(attrs) = post_resource('internal_accounts', attrs) + def update_internal_account(id, attrs) = patch_resource('internal_accounts', id, attrs) + def delete_internal_account(id) = delete_resource('internal_accounts', id) + + def create_direct_debit_mandate(attrs) = post_resource('direct_debit_mandates', attrs) + def update_direct_debit_mandate(id, attrs) = patch_resource('direct_debit_mandates', id, attrs) + def delete_direct_debit_mandate(id) = delete_resource('direct_debit_mandates', id) + + def create_expected_payment(attrs) = post_resource('expected_payments', attrs) + def update_expected_payment(id, attrs) = patch_resource('expected_payments', id, attrs) + def delete_expected_payment(id) = delete_resource('expected_payments', id) + + # Numeral has no DELETE on /returns; the lifecycle "cancel"/"approve" are + # exposed as side-effect actions and would belong in a plugin, not here. + def create_return(attrs) = post_resource('returns', attrs) + def update_return(id, attrs) = patch_resource('returns', id, attrs) + + # Numeral has no DELETE on /reconciliations either; cancel is a lifecycle + # action and is deferred to a future plugin. + def create_reconciliation(attrs) = post_resource('reconciliations', attrs) + def update_reconciliation(id, attrs) = patch_resource('reconciliations', id, attrs) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb new file mode 100644 index 000000000..ba820f7ab --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb @@ -0,0 +1,64 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class AccountHolder < BaseCollection + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + client_resource :account_holder + + def initialize(datasource) + super(datasource, 'MambuAccountHolder') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_account_holder(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_account_holder(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_account_holder(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'name' => a['name'], + 'metadata' => a['metadata'], + 'disabled_at' => a['disabled_at'], + 'created_at' => a['created_at'] + } + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('external_accounts', OneToManySchema.new( + foreign_collection: 'MambuExternalAccount', + origin_key: 'account_holder_id', origin_key_target: 'id' + )) + add_field('internal_accounts', OneToManySchema.new( + foreign_collection: 'MambuInternalAccount', + origin_key: 'account_holder_id', origin_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/balance.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/balance.rb new file mode 100644 index 000000000..bfb160192 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/balance.rb @@ -0,0 +1,75 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Balance < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + client_resource :balance + + def initialize(datasource) + super(datasource, 'MambuBalance') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'type' => a['type'], + 'direction' => a['direction'], + 'amount' => a['amount'], + 'currency' => a['currency'], + 'date' => a['date'], + 'bank_data' => a['bank_data'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb new file mode 100644 index 000000000..43aad1617 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb @@ -0,0 +1,254 @@ +module ForestAdminDatasourceMambuPayments + module Collections + # Shared read path (list / id-lookup / pagination / relation embedding) for + # every Numeral-backed collection. Subclasses declare their REST resource + # via `client_resource` and implement `serialize`; `collection_filters` + # lists the server-filterable fields and `reconcile_filter_operators!` + # narrows each column's advertised operators to what the API can serve. + # rubocop:disable Metrics/ClassLength + class BaseCollection < ForestAdminDatasourceToolkit::Collection + ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException + + STRING_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, + Operators::PRESENT, Operators::BLANK].freeze + NUMBER_OPS = (STRING_OPS + [Operators::GREATER_THAN, Operators::LESS_THAN]).freeze + DATE_OPS = [Operators::EQUAL, Operators::BEFORE, Operators::AFTER, + Operators::PRESENT, Operators::BLANK].freeze + BOOL_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, + Operators::PRESENT, Operators::BLANK].freeze + + # The id column is addressable (detail views, record selection) but the + # Numeral list endpoints have NO `id`/`ids` filter, so it is served by the + # find-by-id short-circuit (extract_id_lookup) and deliberately kept OUT of + # api_filters — a combined `id AND ` predicate raises loudly rather + # than silently sending an ignored param. + ID_OPS = [Operators::EQUAL, Operators::IN].freeze + + class << self + attr_accessor :resource_singular, :resource_plural + end + + # Declares the Numeral REST resource, wiring the read path to the matching + # `list_*` / `find_*` client methods. + def self.client_resource(singular, plural = nil) + self.resource_singular = singular.to_s + self.resource_plural = (plural || "#{singular}s").to_s + end + + def list(_caller, filter, projection) + records = fetch_records(filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + end + + # Numeral exposes no count/aggregate endpoint and paginates by cursor, so + # there is no way to count matching records without scanning every page. + # Collections are therefore declared non-countable (no `enable_count`) and + # Forest never requests an aggregation; this guard makes the unsupported + # path explicit rather than returning a wrong number. + def aggregate(_caller, _filter, _aggregation, _limit = nil) + raise ForestException, + 'Mambu Payments collections are not countable: Numeral exposes no count endpoint.' + end + + # Per-id find_* (Numeral has no batch id filter); public for cross-collection embed. + def fetch_by_ids(ids) + ids = Array(ids).reject { |id| id.to_s.empty? }.uniq + return [] if ids.empty? + + ids.filter_map { |id| client_find(id) } + end + + protected + + # Server-filterable fields the Numeral API accepts. Subclasses override + # `collection_filters` with entries like: + # { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } + # Anything not declared raises UnsupportedOperatorError when filtered on, + # so we never silently return unfiltered results. `id` is intentionally + # absent — see ID_OPS. + def api_filters + collection_filters + end + + def collection_filters + {} + end + + # ManyToOne relations to embed during `list`. Subclasses override with + # entries like: + # { foreign_key: 'connected_account_id', relation_name: 'connected_account', + # collection: 'MambuConnectedAccount' } + def many_to_one_embeds + [] + end + + # Aligns each column's advertised operators with what we can actually + # serve: the declared api_filters for server-side filtering, plus `id` + # (served locally by the find-by-id short-circuit). Run after + # `define_schema`. + def reconcile_filter_operators! + filters = api_filters + schema[:fields].each do |name, field| + next unless field.type == 'Column' + + field.filter_operators = name == 'id' ? ID_OPS : Array(filters.dig(name, :ops)) + end + end + + def fetch_records(filter) + ids = extract_id_lookup(filter.condition_tree) + return fetch_by_ids(ids) if ids + + paginate(filter.page, translate_filters(filter.condition_tree)) + end + + # Maps Forest's offset/limit window onto Numeral's `starting_after` cursor. + def paginate(page, params) + offset = page&.offset.to_i + limit = effective_limit(page) + fetch_window(params, offset, limit)[offset, limit] || [] + end + + def effective_limit(page) + limit = page&.limit + limit.nil? || limit <= 0 ? Client::MAX_PER_PAGE : limit + end + + # Walks the cursor forward until at least `offset + limit` records are + # collected or the API runs out (a short page). + def fetch_window(params, offset, limit) + needed = offset + limit + collected = [] + cursor = nil + loop do + chunk = [needed - collected.size, Client::MAX_PER_PAGE].min + batch = client_list(**cursor_params(params, cursor, chunk)) + collected.concat(batch) + break if batch.size < chunk || collected.size >= needed + + cursor = record_id(batch.last) + break if cursor.to_s.empty? + end + collected + end + + def cursor_params(params, cursor, chunk) + page_params = params.merge(limit: chunk) + page_params[:starting_after] = cursor if cursor + page_params + end + + def record_id(record) + attrs_of(record)['id'] + end + + def client_list(**params) + datasource.client.public_send("list_#{self.class.resource_plural}", **params) + end + + def client_find(id) + datasource.client.public_send("find_#{self.class.resource_singular}", id) + end + + def extract_id_lookup(node) + return nil unless node.is_a?(Leaf) && node.field == 'id' + + case node.operator + when Operators::EQUAL then [node.value] + when Operators::IN then Array(node.value) + end + end + + def translate_filters(condition_tree) + Query::ConditionTreeTranslator.call(condition_tree, api_filters: api_filters) + end + + def project(record, projection) + return record if projection.nil? + + # Relation paths (containing ':') are populated by embed_relations, not + # by the scalar projection. A projection of only relation paths yields + # an empty scalar row — returning the full record here would leak every + # column the caller did not ask for. + wanted = Array(projection).map(&:to_s).reject { |p| p.include?(':') } + wanted.to_h { |k| [k, record[k]] } + end + + def ids_for(caller, filter) + # An id-lookup filter already carries the ids — no need to round-trip to + # the API just to read them back. + ids = extract_id_lookup(filter.condition_tree) + return ids.reject { |id| id.to_s.empty? }.uniq if ids + + list(caller, filter, ['id']).filter_map { |row| row['id'] } + end + + def attrs_of(record) + record.respond_to?(:attributes) ? record.attributes : record.to_h + end + + # Returns the relation prefixes (everything before `:`) requested in the + # projection - e.g. ["connected_account"] for ["id", "connected_account:name"]. + def relations_in(projection) + Array(projection).map(&:to_s).filter_map { |p| p.split(':').first if p.include?(':') }.uniq + end + + # A ManyToOne relation to embed: which foreign key on the row, the + # relation name to populate, and the target collection that resolves and + # serializes the related records. + Embed = Struct.new(:foreign_key, :relation_name, :resolver, keyword_init: true) + + # Embeds the declared ManyToOne relations onto each row. The customizer's + # relation decorator only handles emulated relations, so native datasource + # relations like ours must populate the sub-record themselves. + def embed_relations(rows, records, projection) + return if projection.nil? || many_to_one_embeds.empty? + + sources = records.map { |r| attrs_of(r) } + many_to_one_embeds.each do |embed| + embed_many_to_one(rows, sources, projection, Embed.new( + foreign_key: embed[:foreign_key], + relation_name: embed[:relation_name], + resolver: datasource.get_collection(embed[:collection]) + )) + end + end + + # Bulk-fetches the related records for a ManyToOne relation in a single + # batched pass and writes the serialized record back onto each row. + def embed_many_to_one(rows, sources, projection, embed) + return unless relations_in(projection).include?(embed.relation_name) + + ids = sources.filter_map { |s| s[embed.foreign_key] }.reject { |id| id.to_s.empty? }.uniq + return if ids.empty? + + by_id = embed.resolver.fetch_by_ids(ids).to_h { |raw| [attrs_of(raw)['id'], raw] } + rows.each_with_index do |row, i| + fk_value = sources[i][embed.foreign_key] + next if fk_value.to_s.empty? + + raw = by_id[fk_value] + row[embed.relation_name] = raw && embed.resolver.serialize(raw) + end + end + + # Strips read-only columns and relation fields from a write payload, + # deriving the deny-list from the schema's `is_read_only` flags so it can + # never drift out of sync with the declared columns. + def build_payload(data) + drop = schema[:fields].reject { |_name, field| writable_column?(field) }.keys + data.transform_keys(&:to_s).except(*drop) + end + + def writable_column?(field) + field.type == 'Column' && field.respond_to?(:is_read_only) && !field.is_read_only + end + end + # rubocop:enable Metrics/ClassLength + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/claim.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/claim.rb new file mode 100644 index 000000000..09e637673 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/claim.rb @@ -0,0 +1,98 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Claim < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_TYPE = %w[sepa_non_receipt sepa_value_date_correction].freeze + ENUM_STATUS = %w[created processing sent received accepted rejected].freeze + ENUM_RELATED_PAYMENT = %w[payment_order incoming_payment].freeze + + client_resource :claim + + def initialize(datasource) + super(datasource, 'MambuClaim') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'type' => a['type'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'reason' => a['reason'], + 'value_date' => a['value_date'], + 'connected_account_id' => a['connected_account_id'], + 'related_payment_type' => a['related_payment_type'], + 'related_payment_id' => a['related_payment_id'], + 'related_payment' => a['related_payment'], + 'metadata' => a['metadata'], + 'bank_data' => a['bank_data'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'related_payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'status' => { ops: [Operators::EQUAL, Operators::IN] }, + 'type' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] + end + + private + + # Claims are immutable from Forest's perspective: they arrive from the + # bank network (or the sandbox simulator) and the only way to act on + # them is accept/reject, which belong in a smart-action plugin. We mark + # every column read-only to match. + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_TYPE, + is_read_only: true, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('reason', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('related_payment_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_RELATED_PAYMENT, + is_read_only: true, is_sortable: false)) + # related_payment_id can target a payment_order OR an incoming_payment + # depending on related_payment_type. Forest can't model this polymorphism + # natively, so we expose it as a plain string column. + add_field('related_payment_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('related_payment', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb new file mode 100644 index 000000000..930fdd201 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb @@ -0,0 +1,103 @@ +# rubocop:disable Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class ConnectedAccount < BaseCollection + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + client_resource :connected_account + + def initialize(datasource) + super(datasource, 'MambuConnectedAccount') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'name' => a['name'], + 'distinguished_name' => a['distinguished_name'], + 'type' => a['type'], + 'currency' => a['currency'], + 'bank_id' => a['bank_id'], + 'bank_name' => a['bank_name'], + 'bank_code' => a['bank_code'], + 'bank_code_format' => a['bank_code_format'], + 'bank_address' => a['bank_address'], + 'account_number' => a['account_number'], + 'account_number_format' => a['account_number_format'], + 'settlement_account' => a['settlement_account'], + 'creditor_identifier' => a['creditor_identifier'], + 'legal_entity_identifier' => a['legal_entity_identifier'], + 'receiving_agent' => a['receiving_agent'], + 'services_activated' => a['services_activated'], + 'file_auto_approval' => a['file_auto_approval'], + 'return_auto_approval' => a['return_auto_approval'], + 'incoming_instant_payment_auto_approval' => a['incoming_instant_payment_auto_approval'], + 'address' => a['address'], + 'metadata' => a['metadata'], + 'bank_data' => a['bank_data'], + 'account_number_generation_settings' => a['account_number_generation_settings'], + 'disabled_at' => a['disabled_at'], + 'created_at' => a['created_at'] + } + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('distinguished_name', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('settlement_account', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('legal_entity_identifier', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('receiving_agent', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('services_activated', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('file_auto_approval', ColumnSchema.new(column_type: 'Boolean', is_read_only: true, + is_sortable: false)) + add_field('return_auto_approval', ColumnSchema.new(column_type: 'Boolean', is_read_only: true, + is_sortable: false)) + add_field('incoming_instant_payment_auto_approval', + ColumnSchema.new(column_type: 'Boolean', is_read_only: true, is_sortable: false)) + add_field('address', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('account_number_generation_settings', + ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('transactions', OneToManySchema.new(foreign_collection: 'MambuTransaction', + origin_key: 'connected_account_id', origin_key_target: 'id')) + add_field('payment_orders', OneToManySchema.new(foreign_collection: 'MambuPaymentOrder', + origin_key: 'connected_account_id', origin_key_target: 'id')) + add_field('balances', OneToManySchema.new(foreign_collection: 'MambuBalance', + origin_key: 'connected_account_id', origin_key_target: 'id')) + end + end + end +end +# rubocop:enable Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb new file mode 100644 index 000000000..db8027de6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb @@ -0,0 +1,125 @@ +# rubocop:disable Metrics/ClassLength +module ForestAdminDatasourceMambuPayments + module Collections + class DirectDebitMandate < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_SEQUENCE_TYPE = %w[one_off recurrent first final].freeze + ENUM_SCHEME = %w[sepa bacs ach].freeze + + client_resource :direct_debit_mandate + + def initialize(datasource) + super(datasource, 'MambuDirectDebitMandate') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_direct_debit_mandate(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_direct_debit_mandate(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_direct_debit_mandate(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'external_account_id' => a['external_account_id'], + 'type' => a['type'], + 'scheme' => a['scheme'], + 'status' => a['status'], + 'sequence_type' => a['sequence_type'], + 'reference' => a['reference'], + 'unique_mandate_reference' => a['unique_mandate_reference'], + 'creditor_identifier' => a['creditor_identifier'], + 'signature_date' => a['signature_date'], + 'signature_location' => a['signature_location'], + 'creditor' => a['creditor'], + 'debtor' => a['debtor'], + 'debtor_account' => a['debtor_account'], + 'amendment_information' => a['amendment_information'], + 'custom_fields' => a['custom_fields'], + 'metadata' => a['metadata'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' }, + { foreign_key: 'external_account_id', relation_name: 'external_account', + collection: 'MambuExternalAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: true)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('scheme', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_SCHEME, + is_read_only: false, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('sequence_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_SEQUENCE_TYPE, + is_read_only: false, is_sortable: true)) + add_field('reference', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('unique_mandate_reference', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: true)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('signature_date', ColumnSchema.new(column_type: 'Date', is_read_only: false, is_sortable: true)) + add_field('signature_location', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('creditor', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('debtor', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('debtor_account', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('amendment_information', ColumnSchema.new(column_type: 'Json', is_read_only: false, + is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + add_field('external_account', ManyToOneSchema.new( + foreign_collection: 'MambuExternalAccount', + foreign_key: 'external_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/event.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/event.rb new file mode 100644 index 000000000..104e17019 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/event.rb @@ -0,0 +1,133 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Event < BaseCollection + PolymorphicManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema + + # Maps Numeral's `topic` / `related_object_type` enum values to Forest collection + # names. The polymorphic relation resolver expects the type column to hold the + # target collection name, so we translate at serialize time. + TYPE_TO_COLLECTION = { + 'payment_order' => 'MambuPaymentOrder', + 'transaction' => 'MambuTransaction', + 'incoming_payment' => 'MambuIncomingPayment', + 'expected_payment' => 'MambuExpectedPayment', + 'direct_debit_mandate' => 'MambuDirectDebitMandate', + 'balance' => 'MambuBalance', + 'connected_account' => 'MambuConnectedAccount', + 'account_holder' => 'MambuAccountHolder', + 'internal_account' => 'MambuInternalAccount', + 'external_account' => 'MambuExternalAccount' + }.freeze + + ENUM_STATUS = %w[created delivered pending_retry failed archived].freeze + + client_resource :event + + def initialize(datasource) + super(datasource, 'MambuEvent') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'topic' => a['topic'], + 'type' => a['type'], + 'related_object_id' => a['related_object_id'], + 'related_object_type' => TYPE_TO_COLLECTION[a['related_object_type']] || a['related_object_type'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'webhook_id' => a['webhook_id'], + 'data' => a['data'], + 'created_at' => a['created_at'] + } + end + + protected + + # Numeral's `GET /events` exposes filtering on the polymorphic target id. + # Used by OneToMany relations declared on PaymentOrder/IncomingPayment/etc + # to navigate "events of this resource". `related_object_type` filtering + # is left out because we translate the enum to Forest collection names at + # serialize time — uniqueness of UUIDs makes the type filter redundant + # when filtering by id anyway. + def collection_filters + { + 'related_object_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + # PolymorphicManyToOne is not resolved by the customizer, so we populate + # `related_object` here when the projection requests it. Ids are grouped by + # their (translated) related_object_type so each target collection is hit + # with a single batched fetch_by_ids pass. + def embed_relations(rows, records, projection) + return if projection.nil? || !relations_in(projection).include?('related_object') + + sources = records.map { |r| attrs_of(r) } + caches = build_related_object_caches(sources) + + rows.each_with_index do |row, i| + src = sources[i] + type = TYPE_TO_COLLECTION[src['related_object_type']] + id = src['related_object_id'] + next if type.nil? || id.to_s.empty? + + row['related_object'] = caches.dig(type, id) + end + end + + private + + def build_related_object_caches(sources) + ids_by_collection = Hash.new { |hash, key| hash[key] = [] } + sources.each do |src| + type = TYPE_TO_COLLECTION[src['related_object_type']] + id = src['related_object_id'] + next if type.nil? || id.to_s.empty? + + ids_by_collection[type] << id + end + + ids_by_collection.to_h do |collection_name, ids| + target = datasource.get_collection(collection_name) + by_id = target.fetch_by_ids(ids).to_h do |raw| + [attrs_of(raw)['id'], target.serialize(raw)] + end + [collection_name, by_id] + end + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('topic', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('related_object_id', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('related_object_type', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('webhook_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('related_object', PolymorphicManyToOneSchema.new( + foreign_key: 'related_object_id', + foreign_key_type_field: 'related_object_type', + foreign_collections: TYPE_TO_COLLECTION.values, + foreign_key_targets: TYPE_TO_COLLECTION.values.to_h { |n| [n, 'id'] } + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb new file mode 100644 index 000000000..52766b4d6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb @@ -0,0 +1,132 @@ +# rubocop:disable Metrics/ClassLength +module ForestAdminDatasourceMambuPayments + module Collections + class ExpectedPayment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + client_resource :expected_payment + + def initialize(datasource) + super(datasource, 'MambuExpectedPayment') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_expected_payment(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_expected_payment(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_expected_payment(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'idempotency_key' => a['idempotency_key'], + 'connected_account_id' => a['connected_account_id'], + 'internal_account_id' => a['internal_account_id'], + 'external_account_id' => a['external_account_id'], + 'direction' => a['direction'], + 'amount_from' => a['amount_from'], + 'amount_to' => a['amount_to'], + 'currency' => a['currency'], + 'start_date' => a['start_date'], + 'end_date' => a['end_date'], + 'descriptions' => a['descriptions'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'custom_fields' => a['custom_fields'], + 'metadata' => a['metadata'], + 'created_at' => a['created_at'], + 'updated_at' => a['updated_at'], + 'canceled_at' => a['canceled_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + # The full account records are exposed through the ManyToOne relations + # below rather than as embedded snapshot columns, so a single source of + # truth backs both (mirrors the Transaction collection). + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' }, + { foreign_key: 'internal_account_id', relation_name: 'internal_account', + collection: 'MambuInternalAccount' }, + { foreign_key: 'external_account_id', relation_name: 'external_account', + collection: 'MambuExternalAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: true)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: false)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: false, is_sortable: true)) + add_field('amount_from', ColumnSchema.new(column_type: 'Number', is_read_only: false, is_sortable: false)) + add_field('amount_to', ColumnSchema.new(column_type: 'Number', is_read_only: false, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('start_date', ColumnSchema.new(column_type: 'Date', is_read_only: false, is_sortable: true)) + add_field('end_date', ColumnSchema.new(column_type: 'Date', is_read_only: false, is_sortable: true)) + add_field('descriptions', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + add_field('internal_account', ManyToOneSchema.new( + foreign_collection: 'MambuInternalAccount', + foreign_key: 'internal_account_id', + foreign_key_target: 'id' + )) + add_field('external_account', ManyToOneSchema.new( + foreign_collection: 'MambuExternalAccount', + foreign_key: 'external_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb new file mode 100644 index 000000000..cd8a04b55 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb @@ -0,0 +1,121 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class ExternalAccount < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + client_resource :external_account + + def initialize(datasource) + super(datasource, 'MambuExternalAccount') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_external_account(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_external_account(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_external_account(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'type' => a['type'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'name' => a['name'], + 'holder_name' => a['holder_name'], + 'holder_address' => a['holder_address'], + 'account_number' => a['account_number'], + 'account_number_format' => a['account_number_format'], + 'bank_code' => a['bank_code'], + 'bank_name' => a['bank_name'], + 'bank_address' => a['bank_address'], + 'bank_code_format' => a['bank_code_format'], + 'account_holder_id' => a['account_holder_id'], + 'organization_identification' => a['organization_identification'], + 'company_registration_number' => a['company_registration_number'], + 'company_registration_number_type' => a['company_registration_number_type'], + 'metadata' => a['metadata'], + 'custom_fields' => a['custom_fields'], + 'account_verification' => a['account_verification'], + 'idempotency_key' => a['idempotency_key'], + 'created_at' => a['created_at'], + 'disabled_at' => a['disabled_at'] + } + end + + protected + + def collection_filters + { + 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'account_holder_id', relation_name: 'account_holder', + collection: 'MambuAccountHolder' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('holder_name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('holder_address', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('account_holder_id', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: true)) + add_field('organization_identification', ColumnSchema.new(column_type: 'Json', is_read_only: false, + is_sortable: false)) + add_field('company_registration_number', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('company_registration_number_type', + ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('account_verification', ColumnSchema.new(column_type: 'Json', is_read_only: true, + is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('account_holder', ManyToOneSchema.new( + foreign_collection: 'MambuAccountHolder', + foreign_key: 'account_holder_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/file.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/file.rb new file mode 100644 index 000000000..46bdfc1ff --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/file.rb @@ -0,0 +1,89 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class File < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[incoming outgoing].freeze + ENUM_STATUS = %w[created approved canceled sent rejected processed received].freeze + + client_resource :file + + def initialize(datasource) + super(datasource, 'MambuFile') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'connected_account_ids' => a['connected_account_ids'], + 'direction' => a['direction'], + 'category' => a['category'], + 'format' => a['format'], + 'filename' => a['filename'], + 'size' => a['size'], + 'summary' => a['summary'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'bank_data' => a['bank_data'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + # The API also returns connected_account_ids (an array) for files that + # aggregate operations across multiple accounts; surfaced as Json since + # Forest can't model an array of foreign keys natively. + add_field('connected_account_ids', ColumnSchema.new(column_type: 'Json', is_read_only: true, + is_sortable: false)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: true)) + add_field('category', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('format', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('filename', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('size', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: true)) + add_field('summary', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb new file mode 100644 index 000000000..12ddcee67 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb @@ -0,0 +1,120 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class IncomingPayment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + client_resource :incoming_payment + + def initialize(datasource) + super(datasource, 'MambuIncomingPayment') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'type' => a['type'], + 'status' => a['status'], + 'amount' => a['amount'], + 'currency' => a['currency'], + 'end_to_end_id' => a['end_to_end_id'], + 'uetr' => a['uetr'], + 'reference' => a['reference'], + 'structured_reference' => a['structured_reference'], + 'value_date' => a['value_date'], + 'booking_date' => a['booking_date'], + 'originating_account' => a['originating_account'], + 'receiving_account' => a['receiving_account'], + 'internal_account_id' => a['internal_account_id'], + 'external_account_id' => a['external_account_id'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'return_information' => a['return_information'], + 'custom_fields' => a['custom_fields'], + 'metadata' => a['metadata'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' }, + { foreign_key: 'internal_account_id', relation_name: 'internal_account', + collection: 'MambuInternalAccount' }, + { foreign_key: 'external_account_id', relation_name: 'external_account', + collection: 'MambuExternalAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('uetr', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('reference', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('structured_reference', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('return_information', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + add_field('internal_account', ManyToOneSchema.new( + foreign_collection: 'MambuInternalAccount', + foreign_key: 'internal_account_id', + foreign_key_target: 'id' + )) + add_field('external_account', ManyToOneSchema.new( + foreign_collection: 'MambuExternalAccount', + foreign_key: 'external_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb new file mode 100644 index 000000000..58b2e9478 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb @@ -0,0 +1,136 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class InternalAccount < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + client_resource :internal_account + + def initialize(datasource) + super(datasource, 'MambuInternalAccount') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_internal_account(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_internal_account(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_internal_account(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'type' => a['type'], + 'name' => a['name'], + 'holder_name' => a['holder_name'], + 'alternative_holder_names' => a['alternative_holder_names'], + 'connected_account_ids' => a['connected_account_ids'], + 'account_number' => a['account_number'], + 'account_number_format' => a['account_number_format'], + 'bank_code' => a['bank_code'], + 'bank_name' => a['bank_name'], + 'bank_address' => a['bank_address'], + 'bank_code_format' => a['bank_code_format'], + 'holder_address' => a['holder_address'], + 'account_holder_id' => a['account_holder_id'], + 'creditor_identifier' => a['creditor_identifier'], + 'organization_identification' => a['organization_identification'], + 'customer_bic' => a['customer_bic'], + 'distinguished_name' => a['distinguished_name'], + 'currencies' => a['currencies'], + 'cbs_source' => a['cbs_source'], + 'cbs_account_id' => a['cbs_account_id'], + 'cbs_account_type' => a['cbs_account_type'], + 'synchronized_with_bank' => a['synchronized_with_bank'], + 'metadata' => a['metadata'], + 'bank_data' => a['bank_data'], + 'custom_fields' => a['custom_fields'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'account_holder_id', relation_name: 'account_holder', + collection: 'MambuAccountHolder' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('holder_name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('alternative_holder_names', ColumnSchema.new(column_type: 'Json', is_read_only: false, + is_sortable: false)) + add_field('connected_account_ids', ColumnSchema.new(column_type: 'Json', is_read_only: false, + is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('holder_address', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('account_holder_id', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: true)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('organization_identification', ColumnSchema.new(column_type: 'Json', is_read_only: false, + is_sortable: false)) + add_field('customer_bic', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('distinguished_name', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('currencies', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('cbs_source', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('cbs_account_id', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('cbs_account_type', ColumnSchema.new(column_type: 'String', is_read_only: false, + is_sortable: false)) + add_field('synchronized_with_bank', ColumnSchema.new(column_type: 'Boolean', is_read_only: false, + is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('account_holder', ManyToOneSchema.new( + foreign_collection: 'MambuAccountHolder', + foreign_key: 'account_holder_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb new file mode 100644 index 000000000..69666e36c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb @@ -0,0 +1,88 @@ +module ForestAdminDatasourceMambuPayments + module Collections + # Payee Verification Requests are emitted by Numeral when an outgoing + # verification is sent (via the `Trigger payee verification` smart + # action on external accounts) or when an incoming verification arrives + # from the network. From Forest's perspective they are read-only: send + # and simulate are lifecycle operations exposed as smart-action plugins + # (see TriggerPayeeVerification) rather than collection writes. + class PayeeVerificationRequest < BaseCollection + ENUM_STATUS = %w[completed failed].freeze + ENUM_FAILURE_CODE = %w[business_error technical_error psp_technical_error].freeze + ENUM_DIRECTION = %w[outgoing incoming].freeze + ENUM_SCHEME = %w[vop].freeze + ENUM_MATCHING_RESULT = %w[match close_match no_match impossible_match].freeze + + client_resource :payee_verification_request + + def initialize(datasource) + super(datasource, 'MambuPayeeVerificationRequest') + define_schema + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'status' => a['status'], + 'failure_code' => a['failure_code'], + 'status_details' => a['status_details'], + 'direction' => a['direction'], + 'scheme' => a['scheme'], + 'request' => a['request'], + 'matching_result' => a['matching_result'], + 'payee_suggested_name' => a['payee_suggested_name'], + 'matching_details' => a['matching_details'], + 'scheme_data' => a['scheme_data'], + 'metadata' => a['metadata'], + 'response_received_at' => a['response_received_at'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'status' => { ops: [Operators::EQUAL, Operators::IN] }, + 'direction' => { ops: [Operators::EQUAL, Operators::IN] }, + 'scheme' => { ops: [Operators::EQUAL, Operators::IN] }, + 'matching_result' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('failure_code', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_FAILURE_CODE, + is_read_only: true, is_sortable: false)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: true)) + add_field('scheme', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_SCHEME, + is_read_only: true, is_sortable: true)) + # request, matching_details, scheme_data are nested objects with their + # own sub-fields (payee_identification, scheme_request_id, ...). Forest + # can't model nested columns natively, so we expose them as Json + # snapshots — matches how IncomingPayment handles originating_account. + add_field('request', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('matching_result', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_MATCHING_RESULT, + is_read_only: true, is_sortable: true)) + add_field('payee_suggested_name', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('matching_details', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('scheme_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('response_received_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb new file mode 100644 index 000000000..c8617206b --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb @@ -0,0 +1,136 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class PaymentCapture < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_TYPE = %w[charge chargeback refund].freeze + ENUM_SOURCE = %w[api reporting_file].freeze + ENUM_RECONCILIATION_STATUS = %w[unreconciled reconciled partially_reconciled].freeze + + client_resource :payment_capture + + def initialize(datasource) + super(datasource, 'MambuPaymentCapture') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'idempotency_key' => a['idempotency_key'], + 'connected_account_id' => a['connected_account_id'], + 'type' => a['type'], + 'source' => a['source'], + 'amount' => a['amount'], + 'original_payment_amount' => a['original_payment_amount'], + 'currency' => a['currency'], + 'date' => a['date'], + 'value_date' => a['value_date'], + 'remittance_date' => a['remittance_date'], + 'remittance_reference' => a['remittance_reference'], + 'transaction_reference' => a['transaction_reference'], + 'authorization_id' => a['authorization_id'], + 'payment_reference' => a['payment_reference'], + 'network' => a['network'], + 'merchant_id' => a['merchant_id'], + 'fee_amount' => a['fee_amount'], + 'fee_amount_currency' => a['fee_amount_currency'], + 'net_amount' => a['net_amount'], + 'net_amount_currency' => a['net_amount_currency'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'cbs_data' => a['cbs_data'], + 'lending' => a['lending'], + 'metadata' => a['metadata'], + 'canceled_at' => a['canceled_at'], + 'updated_at' => a['updated_at'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'type' => { ops: [Operators::EQUAL, Operators::IN] }, + 'source' => { ops: [Operators::EQUAL, Operators::IN] }, + 'reconciliation_status' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] + end + + private + + # Payment captures are emitted by PSPs (or registered manually via API + # to reconcile reporting files). From Forest's perspective they're + # read-only: create / update / cancel exist on the Numeral API but are + # lifecycle operations better expressed as smart-action plugins later + # (same approach as payment_orders' approve/cancel). + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_TYPE, + is_read_only: true, is_sortable: true)) + add_field('source', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_SOURCE, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('original_payment_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, + is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('remittance_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('remittance_reference', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('transaction_reference', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('authorization_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('payment_reference', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('network', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('merchant_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('fee_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('fee_amount_currency', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('net_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('net_amount_currency', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'Enum', + enum_values: ENUM_RECONCILIATION_STATUS, + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('cbs_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('lending', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb new file mode 100644 index 000000000..8ca9d915e --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb @@ -0,0 +1,132 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class PaymentOrder < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + client_resource :payment_order + + def initialize(datasource) + super(datasource, 'MambuPaymentOrder') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_payment_order(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_payment_order(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_payment_order(id) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'connected_account_id' => a['connected_account_id'], + 'receiving_account_id' => a['receiving_account_id'], + 'type' => a['type'], + 'direction' => a['direction'], + 'status' => a['status'], + 'amount' => a['amount'], + 'currency' => a['currency'], + 'reference' => a['reference'], + 'purpose' => a['purpose'], + 'end_to_end_id' => a['end_to_end_id'], + 'idempotency_key' => a['idempotency_key'], + 'requested_execution_date' => a['requested_execution_date'], + 'value_date' => a['value_date'], + 'initiated_at' => a['initiated_at'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'originating_account' => a['originating_account'], + 'receiving_account' => a['receiving_account'], + 'metadata' => a['metadata'], + 'custom_fields' => a['custom_fields'], + 'created_at' => a['created_at'] + } + end + + protected + + # NOTE: server-side filters verified against Numeral's `GET /payment_orders` docs. + # Add new entries here (status, direction, currency, created_at ranges, …) as + # we confirm them — anything not declared raises a clear error rather than + # silently returning unfiltered results. + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + # Numeral's list endpoint exposes the receiving external account + # under the `external_account_id` query param. + 'receiving_account_id' => { ops: [Operators::EQUAL, Operators::IN], + param: 'external_account_id' } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' }, + { foreign_key: 'receiving_account_id', relation_name: 'external_account', + collection: 'MambuExternalAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: true)) + add_field('receiving_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: false, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: false, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('reference', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('purpose', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('requested_execution_date', ColumnSchema.new(column_type: 'Date', is_read_only: false, + is_sortable: true)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('initiated_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + add_field('external_account', ManyToOneSchema.new( + foreign_collection: 'MambuExternalAccount', + foreign_key: 'receiving_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb new file mode 100644 index 000000000..f9cdb5e57 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb @@ -0,0 +1,93 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Reconciliation < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_MATCH_TYPE = %w[manual auto].freeze + ENUM_PAYMENT_TYPE = %w[payment_order incoming_payment return expected_payment payment_capture].freeze + + client_resource :reconciliation + + def initialize(datasource) + super(datasource, 'MambuReconciliation') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_reconciliation(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_reconciliation(id, payload) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'transaction_id' => a['transaction_id'], + 'payment_id' => a['payment_id'], + 'payment_type' => a['payment_type'], + 'amount' => a['amount'], + 'match_type' => a['match_type'], + 'metadata' => a['metadata'], + 'canceled_at' => a['canceled_at'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'transaction_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'payment_type' => { ops: [Operators::EQUAL, Operators::IN] }, + 'match_type' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'transaction_id', relation_name: 'transaction', collection: 'MambuTransaction' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + # transaction_id is set on create and never mutated afterwards — Numeral + # rejects PATCH on anything besides metadata. + add_field('transaction_id', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: true)) + # payment_id is polymorphic in Numeral (payment_order / incoming_payment / + # return / expected_payment / payment_capture, discriminated by + # payment_type). Forest can't model that natively, so we expose it as + # a plain string column rather than a typed ManyToOne. + add_field('payment_id', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('payment_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_PAYMENT_TYPE, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: false, is_sortable: false)) + add_field('match_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_MATCH_TYPE, + is_read_only: true, is_sortable: true)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('transaction', ManyToOneSchema.new( + foreign_collection: 'MambuTransaction', + foreign_key: 'transaction_id', + foreign_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/return.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/return.rb new file mode 100644 index 000000000..b15b0ab96 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/return.rb @@ -0,0 +1,132 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class Return < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[credit debit].freeze + ENUM_TYPE = %w[sepa sepa_instant].freeze + ENUM_RETURN_TYPE = %w[return refund reversal].freeze + ENUM_STATUS = %w[pending sent processing executed received rejected].freeze + ENUM_RELATED_PAYMENT = %w[payment_order incoming_payment].freeze + + client_resource :return + + def initialize(datasource) + super(datasource, 'MambuReturn') + define_schema + define_relations + reconcile_filter_operators! + end + + def create(_caller, data) + serialize(datasource.client.create_return(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_return(id, payload) } + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'related_payment_id' => a['related_payment_id'], + 'related_payment_type' => a['related_payment_type'], + 'related_payment_suspended' => a['related_payment_suspended'], + 'return_type' => a['return_type'], + 'type' => a['type'], + 'direction' => a['direction'], + 'status' => a['status'], + 'status_details' => a['status_details'], + 'return_reason' => a['return_reason'], + 'amount' => a['amount'], + 'currency' => a['currency'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'value_date' => a['value_date'], + 'booking_date' => a['booking_date'], + 'originating_account' => a['originating_account'], + 'receiving_account' => a['receiving_account'], + 'aggregation_reference' => a['aggregation_reference'], + 'file_id' => a['file_id'], + 'metadata' => a['metadata'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'related_payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'status' => { ops: [Operators::EQUAL, Operators::IN] }, + 'type' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + # related_payment_id can target a payment_order OR an incoming_payment + # depending on related_payment_type — we expose it as a plain string + # rather than a typed relation (Forest can't model the polymorphism). + add_field('related_payment_id', ColumnSchema.new(column_type: 'String', + is_read_only: false, is_sortable: false)) + add_field('related_payment_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_RELATED_PAYMENT, + is_read_only: true, is_sortable: false)) + add_field('related_payment_suspended', ColumnSchema.new(column_type: 'Boolean', + is_read_only: false, is_sortable: false)) + add_field('return_type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_RETURN_TYPE, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_TYPE, + is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_STATUS, + is_read_only: false, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('return_reason', ColumnSchema.new(column_type: 'String', is_read_only: false, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('aggregation_reference', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('file_id', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + end + end + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb new file mode 100644 index 000000000..2a00d0d13 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb @@ -0,0 +1,113 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Transaction < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + client_resource :transaction + + def initialize(datasource) + super(datasource, 'MambuTransaction') + define_schema + define_relations + reconcile_filter_operators! + end + + def serialize(record) + a = attrs_of(record) + { + 'id' => a['id'], + 'object' => a['object'], + 'connected_account_id' => a['connected_account_id'], + 'category' => a['category'], + 'direction' => a['direction'], + 'amount' => a['amount'], + 'currency' => a['currency'], + 'description' => a['description'], + 'structured_reference' => a['structured_reference'], + 'value_date' => a['value_date'], + 'booking_date' => a['booking_date'], + 'internal_account_id' => a['internal_account_id'], + 'external_account_id' => a['external_account_id'], + 'uetr' => a['uetr'], + 'bank_data' => a['bank_data'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], + 'custom_fields' => a['custom_fields'], + 'created_at' => a['created_at'] + } + end + + protected + + def collection_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } + end + + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' }, + { foreign_key: 'internal_account_id', relation_name: 'internal_account', + collection: 'MambuInternalAccount' }, + { foreign_key: 'external_account_id', relation_name: 'external_account', + collection: 'MambuExternalAccount' } + ] + end + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', is_primary_key: true, + is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: true)) + add_field('category', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('description', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('structured_reference', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', + is_read_only: true, is_sortable: false)) + add_field('uetr', ColumnSchema.new(column_type: 'String', is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', is_read_only: true, + is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', is_read_only: true, is_sortable: true)) + end + + def define_relations + add_field('connected_account', ManyToOneSchema.new( + foreign_collection: 'MambuConnectedAccount', + foreign_key: 'connected_account_id', + foreign_key_target: 'id' + )) + add_field('internal_account', ManyToOneSchema.new( + foreign_collection: 'MambuInternalAccount', + foreign_key: 'internal_account_id', + foreign_key_target: 'id' + )) + add_field('external_account', ManyToOneSchema.new( + foreign_collection: 'MambuExternalAccount', + foreign_key: 'external_account_id', + foreign_key_target: 'id' + )) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/configuration.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/configuration.rb new file mode 100644 index 000000000..bc7a505b5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/configuration.rb @@ -0,0 +1,36 @@ +module ForestAdminDatasourceMambuPayments + class Configuration + DEFAULT_BASE_URL = 'https://api.numeral.io'.freeze + SANDBOX_BASE_URL = 'https://api.sandbox.numeral.io'.freeze + API_VERSION = 'v1'.freeze + + attr_reader :api_key, :base_url, :open_timeout, :timeout + + def initialize(api_key:, base_url: nil, sandbox: false, open_timeout: 5, timeout: 30) + @api_key = api_key + @base_url = base_url || (sandbox ? SANDBOX_BASE_URL : DEFAULT_BASE_URL) + @open_timeout = open_timeout + @timeout = timeout + validate! + end + + def url + "#{@base_url.chomp("/")}/#{API_VERSION}" + end + + private + + def validate! + missing = [] + missing << 'api_key' if blank?(@api_key) + return if missing.empty? + + raise ConfigurationError, + "ForestAdminDatasourceMambuPayments missing required config: #{missing.join(", ")}" + end + + def blank?(value) + value.nil? || value.to_s.strip.empty? + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb new file mode 100644 index 000000000..282ac5df8 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb @@ -0,0 +1,35 @@ +module ForestAdminDatasourceMambuPayments + class Datasource < ForestAdminDatasourceToolkit::Datasource + attr_reader :client, :configuration + + def initialize(api_key:, base_url: nil, sandbox: false) + super() + @configuration = Configuration.new(api_key: api_key, base_url: base_url, sandbox: sandbox) + @client = Client.new(@configuration) + + register_collections + end + + private + + def register_collections + add_collection(Collections::ConnectedAccount.new(self)) + add_collection(Collections::PaymentOrder.new(self)) + add_collection(Collections::Transaction.new(self)) + add_collection(Collections::Balance.new(self)) + add_collection(Collections::AccountHolder.new(self)) + add_collection(Collections::ExternalAccount.new(self)) + add_collection(Collections::InternalAccount.new(self)) + add_collection(Collections::IncomingPayment.new(self)) + add_collection(Collections::DirectDebitMandate.new(self)) + add_collection(Collections::ExpectedPayment.new(self)) + add_collection(Collections::Event.new(self)) + add_collection(Collections::File.new(self)) + add_collection(Collections::Return.new(self)) + add_collection(Collections::Claim.new(self)) + add_collection(Collections::Reconciliation.new(self)) + add_collection(Collections::PaymentCapture.new(self)) + add_collection(Collections::PayeeVerificationRequest.new(self)) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/disable_search.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/disable_search.rb new file mode 100644 index 000000000..14ed267da --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/disable_search.rb @@ -0,0 +1,31 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + # Numeral's list endpoints have no free-text search. Forest emulates search + # by OR-ing a condition over every searchable column, which the + # ConditionTreeTranslator deliberately rejects (it cannot push an OR to + # Numeral) — so an operator typing in the search bar would get an error. + # + # This plugin turns the search bar off on every Mambu collection, which is + # the honest behaviour: the API can filter (per `api_filters`) but not + # search. Install it once at the datasource level: + # + # @agent.use(ForestAdminDatasourceMambuPayments::Plugins::DisableSearch, {}) + class DisableSearch < ForestAdminDatasourceCustomizer::Plugins::Plugin + COLLECTIONS = %w[ + MambuConnectedAccount MambuPaymentOrder MambuTransaction MambuBalance + MambuAccountHolder MambuExternalAccount MambuInternalAccount + MambuIncomingPayment MambuDirectDebitMandate MambuExpectedPayment + MambuEvent MambuFile MambuReturn MambuClaim MambuReconciliation + MambuPaymentCapture MambuPayeeVerificationRequest + ].freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + Helpers.require_datasource!(datasource_customizer, self.class) + + COLLECTIONS.each do |name| + datasource_customizer.customize_collection(name, &:disable_search) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb new file mode 100644 index 000000000..55a6795b7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb @@ -0,0 +1,94 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + # Shared helpers for Mambu Payments plugins: input normalization, host + # record id resolution, and per-id rescue logic for bulk transitions. + module Helpers + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + + SCOPE_KEYS = %i[single bulk].freeze + SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze + + module_function + + # Relation plugins must be installed at the datasource level + # (`@agent.use(plugin, {})`) so they can customize several collections at + # once. Raises a clear error when a caller installs one on a single + # collection instead. + def require_datasource!(datasource_customizer, plugin_class) + return if datasource_customizer + + name = plugin_class.is_a?(Class) ? plugin_class.name.split('::').last : plugin_class + raise ArgumentError, + "#{name} must be installed at the datasource level via @agent.use(plugin, {})" + end + + def normalize_scopes(value) + list = Array(value).map(&:to_sym).uniq + list = SCOPE_KEYS if list.empty? + unknown = list - SCOPE_KEYS + return list if unknown.empty? + + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "Unknown scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}." + end + + def resolve_ids(context, field) + records = context.get_records([field]) + records = [records].compact unless records.is_a?(Array) + records.filter_map { |r| r[field] || r[field.to_sym] } + rescue StandardError => e + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] failed to resolve ids from '#{field}': " \ + "#{e.class}: #{e.message}" + ) + [] + end + + # Per-id rescue so a single API failure doesn't abort the remaining ids. + def each_with_rescue(ids, label) + succeeded = [] + failed = [] + ids.each do |id| + yield id + succeeded << id + rescue StandardError => e + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] #{label} failed for ##{id}: #{e.class}: #{e.message}" + ) + failed << [id, "#{e.class}: #{e.message}"] + end + [succeeded, failed] + end + + def present?(value) + !value.nil? && value.to_s != '' + end + + def to_int(value) + return nil unless present?(value) + + Integer(value.to_s) + rescue ArgumentError, TypeError + nil + end + + def write_back(context, field, value) + return :skipped if field.nil? || value.nil? + + context.collection.update(context.filter, { field => value }) + :ok + rescue StandardError => e + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] write-back to '#{field}' failed: #{e.class}: #{e.message}" + ) + [:failed, "#{e.class}: #{e.message}"] + end + + def write_back_warning(writeback) + return nil unless writeback.is_a?(Array) && writeback.first == :failed + + " (warning: could not write the id back to the host record: #{writeback.last})" + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/messages.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/messages.rb new file mode 100644 index 000000000..60946db56 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/messages.rb @@ -0,0 +1,30 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Messages + module_function + + def success(succeeded, failed, noun:, verb_past:) + [succeeded_phrase(succeeded, noun, verb_past), failed_phrase(failed, noun)].compact.join(' ') + end + + def all_failed(failed, noun:, verb:) + return "Failed to #{verb} #{noun} ##{failed.first.first}: #{failed.first.last}" if failed.size == 1 + + "Failed to #{verb} all #{failed.size} #{noun}s. First error: #{failed.first.last}" + end + + def succeeded_phrase(succeeded, noun, verb_past) + return nil if succeeded.empty? + return "#{noun.capitalize} ##{succeeded.first} #{verb_past}." if succeeded.size == 1 + + "#{succeeded.size} #{noun}s #{verb_past}." + end + + def failed_phrase(failed, _noun) + return nil if failed.empty? + + "#{failed.size} failed: #{failed.map(&:first).join(", ")}." + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/holder_link_plugin.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/holder_link_plugin.rb new file mode 100644 index 000000000..05b0439b2 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/holder_link_plugin.rb @@ -0,0 +1,56 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Base for AccountHolder links reached transitively through an account + # relation. The `host` imports `account_holder_id` from a related account, + # exposes a ManyToOne to AccountHolder, and gets a TwoStepHolderFilter + # (which rewrites holder filters onto `local_fk`); AccountHolder gets the + # reciprocal OneToMany. Subclasses configure it declaratively: + # + # class LinkAccountHolderToIncomingPayments < HolderLinkPlugin + # link host: 'MambuIncomingPayment', name: 'incoming_payments', + # local_fk: 'internal_account_id', intermediate: 'MambuInternalAccount', + # import_path: 'internal_account:account_holder_id' + # end + # + # Install at the datasource level: @agent.use(plugin, {}). + class HolderLinkPlugin < ForestAdminDatasourceCustomizer::Plugins::Plugin + ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze + FK_NAME = 'account_holder_id'.freeze + + class << self + attr_reader :config + end + + # rubocop:disable Metrics/ParameterLists + def self.link(host:, name:, local_fk:, intermediate:, import_path:, many_to_one_name: 'account_holder') + @config = { + host: host, name: name, local_fk: local_fk, intermediate: intermediate, + import_path: import_path, many_to_one_name: many_to_one_name + } + end + # rubocop:enable Metrics/ParameterLists + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) + + cfg = self.class.config + datasource_customizer.customize_collection(cfg[:host]) do |c| + c.import_field(FK_NAME, path: cfg[:import_path], readonly: true) + c.add_many_to_one_relation(cfg[:many_to_one_name], ACCOUNT_HOLDER, + foreign_key: FK_NAME, foreign_key_target: 'id') + TwoStepHolderFilter.install(c, + fk_name: FK_NAME, + local_fk: cfg[:local_fk], + intermediate_collection: cfg[:intermediate]) + end + + datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| + c.add_one_to_many_relation(cfg[:name], cfg[:host], + origin_key: FK_NAME, origin_key_target: 'id') + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates.rb new file mode 100644 index 000000000..3dd443237 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates.rb @@ -0,0 +1,14 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # AccountHolder <-> DirectDebitMandate, transitive via the external account + # (DirectDebitMandate.external_account.account_holder_id). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkAccountHolderToDirectDebitMandates < HolderLinkPlugin + link host: 'MambuDirectDebitMandate', name: 'direct_debit_mandates', + local_fk: 'external_account_id', intermediate: 'MambuExternalAccount', + import_path: 'external_account:account_holder_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments.rb new file mode 100644 index 000000000..91f6f2f81 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments.rb @@ -0,0 +1,14 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # AccountHolder <-> IncomingPayment, transitive via the internal account + # (IncomingPayment.internal_account.account_holder_id). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkAccountHolderToIncomingPayments < HolderLinkPlugin + link host: 'MambuIncomingPayment', name: 'incoming_payments', + local_fk: 'internal_account_id', intermediate: 'MambuInternalAccount', + import_path: 'internal_account:account_holder_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates.rb new file mode 100644 index 000000000..7f798a0e1 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # DirectDebitMandate.external_account ManyToOne. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkExternalAccountToDirectDebitMandates < OneToManyLinkPlugin + link host: 'MambuExternalAccount', to: 'MambuDirectDebitMandate', + name: 'direct_debit_mandates', origin_key: 'external_account_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments.rb new file mode 100644 index 000000000..0c7d9d0f4 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # IncomingPayment.external_account ManyToOne. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkExternalAccountToIncomingPayments < OneToManyLinkPlugin + link host: 'MambuExternalAccount', to: 'MambuIncomingPayment', + name: 'incoming_payments', origin_key: 'external_account_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders.rb new file mode 100644 index 000000000..6d1e22b41 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # PaymentOrder.external_account ManyToOne (receiving account). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkExternalAccountToPaymentOrders < OneToManyLinkPlugin + link host: 'MambuExternalAccount', to: 'MambuPaymentOrder', + name: 'payment_orders', origin_key: 'receiving_account_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events.rb new file mode 100644 index 000000000..223557bac --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuIncomingPayment over Event.related_object_id + # (events emitted for this incoming payment). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkIncomingPaymentToEvents < OneToManyLinkPlugin + link host: 'MambuIncomingPayment', to: 'MambuEvent', + name: 'events', origin_key: 'related_object_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments.rb new file mode 100644 index 000000000..a6941b252 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments.rb @@ -0,0 +1,21 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # IncomingPayment <-> ExpectedPayment matched through two reconciliations + # sharing a transaction (cross-pivot resolution). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkIncomingPaymentToExpectedPayments < TwoStepLinkPlugin + link owner: 'MambuIncomingPayment', filtered: 'MambuExpectedPayment', + name: 'matched_expected_payments', foreign_key: 'incoming_payment_id' + + def install_source_filter(collection) + TwoStepCrossReconciliationFilter.install(collection, + fk_name: 'incoming_payment_id', + src_payment_type: 'incoming_payment', + dst_payment_type: 'expected_payment', + target_field: 'id') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns.rb new file mode 100644 index 000000000..993881a92 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns.rb @@ -0,0 +1,12 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuIncomingPayment over Return.related_payment_id. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkIncomingPaymentToReturns < OneToManyLinkPlugin + link host: 'MambuIncomingPayment', to: 'MambuReturn', + name: 'returns', origin_key: 'related_payment_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions.rb new file mode 100644 index 000000000..c3826022c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions.rb @@ -0,0 +1,20 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # IncomingPayment <-> Transaction through the Reconciliation pivot + # (Reconciliation.payment_id + payment_type = incoming_payment). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkIncomingPaymentToTransactions < TwoStepLinkPlugin + link owner: 'MambuIncomingPayment', filtered: 'MambuTransaction', + name: 'transactions', foreign_key: 'incoming_payment_id' + + def install_source_filter(collection) + TwoStepReconciliationFilter.install(collection, + fk_name: 'incoming_payment_id', + payment_type: 'incoming_payment', + target_field: 'id') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances.rb new file mode 100644 index 000000000..158c86e72 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances.rb @@ -0,0 +1,17 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # InternalAccount <-> Balance, transitive via + # InternalAccount.connected_account_ids vs Balance.connected_account_id. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkInternalAccountToBalances < TwoStepLinkPlugin + link owner: 'MambuInternalAccount', filtered: 'MambuBalance', + name: 'balances', foreign_key: 'internal_account_id' + + def install_source_filter(collection) + TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments.rb new file mode 100644 index 000000000..7fecca761 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuInternalAccount for the native + # IncomingPayment.internal_account ManyToOne. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkInternalAccountToIncomingPayments < OneToManyLinkPlugin + link host: 'MambuInternalAccount', to: 'MambuIncomingPayment', + name: 'incoming_payments', origin_key: 'internal_account_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders.rb new file mode 100644 index 000000000..2846ddf98 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders.rb @@ -0,0 +1,17 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # InternalAccount <-> PaymentOrder, transitive via + # InternalAccount.connected_account_ids vs PaymentOrder.connected_account_id. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkInternalAccountToPaymentOrders < TwoStepLinkPlugin + link owner: 'MambuInternalAccount', filtered: 'MambuPaymentOrder', + name: 'payment_orders', foreign_key: 'internal_account_id' + + def install_source_filter(collection) + TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events.rb new file mode 100644 index 000000000..c142a8fed --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuPaymentOrder over Event.related_object_id + # (events emitted for this payment order). + # Install at the datasource level: @agent.use(plugin, {}). + class LinkPaymentOrderToEvents < OneToManyLinkPlugin + link host: 'MambuPaymentOrder', to: 'MambuEvent', + name: 'events', origin_key: 'related_object_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder.rb new file mode 100644 index 000000000..d216265ee --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder.rb @@ -0,0 +1,15 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # PaymentOrder <-> AccountHolder of the receiving external account. + # Named `receiving_account_holder` to disambiguate from the originating side. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkPaymentOrderToReceivingAccountHolder < HolderLinkPlugin + link host: 'MambuPaymentOrder', name: 'payment_orders', + local_fk: 'receiving_account_id', intermediate: 'MambuExternalAccount', + import_path: 'external_account:account_holder_id', + many_to_one_name: 'receiving_account_holder' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns.rb new file mode 100644 index 000000000..fd2326bf9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns.rb @@ -0,0 +1,12 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuPaymentOrder over Return.related_payment_id. + # Install at the datasource level: @agent.use(plugin, {}). + class LinkPaymentOrderToReturns < OneToManyLinkPlugin + link host: 'MambuPaymentOrder', to: 'MambuReturn', + name: 'returns', origin_key: 'related_payment_id' + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions.rb new file mode 100644 index 000000000..97db09278 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions.rb @@ -0,0 +1,51 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable PaymentOrder <-> Transaction link. + # Transaction has no native payment_order_id; the relation is mediated by + # MambuReconciliation (Reconciliation.payment_id + payment_type discriminator). + # See TwoStepReconciliationFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToTransactions, + # {} + # ) + class LinkPaymentOrderToTransactions < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + TRANSACTION = 'MambuTransaction'.freeze + FK_NAME = 'payment_order_id'.freeze + PAYMENT_TYPE = 'payment_order'.freeze + ONE_TO_MANY_NAME = 'transactions'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) + + datasource_customizer.customize_collection(TRANSACTION) do |c| + # Virtual column: Transaction has no native payment_order_id. + # Reverse lookup would require scanning all reconciliations — kept + # nil per record; only EQUAL/IN filters are rewritten via the + # TwoStepReconciliationFilter below. + c.add_field(FK_NAME, ComputedDefinition.new( + column_type: 'String', + dependencies: ['id'], + values: proc { |records, _ctx| records.map { nil } } + )) + TwoStepReconciliationFilter.install(c, + fk_name: FK_NAME, + payment_type: PAYMENT_TYPE, + target_field: 'id') + end + + datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, TRANSACTION, + origin_key: FK_NAME, + origin_key_target: 'id') + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/one_to_many_link_plugin.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/one_to_many_link_plugin.rb new file mode 100644 index 000000000..7de138fce --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/one_to_many_link_plugin.rb @@ -0,0 +1,35 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Base for the "simple" reciprocal OneToMany links: the target already + # carries a native foreign key, so the plugin only declares the relation + # on the host. Subclasses configure it declaratively: + # + # class LinkExternalAccountToIncomingPayments < OneToManyLinkPlugin + # link host: 'MambuExternalAccount', to: 'MambuIncomingPayment', + # name: 'incoming_payments', origin_key: 'external_account_id' + # end + # + # Install at the datasource level: @agent.use(plugin, {}). + class OneToManyLinkPlugin < ForestAdminDatasourceCustomizer::Plugins::Plugin + class << self + attr_reader :config + end + + def self.link(host:, to:, name:, origin_key:) + @config = { host: host, to: to, name: name, origin_key: origin_key } + end + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) + + cfg = self.class.config + datasource_customizer.customize_collection(cfg[:host]) do |c| + c.add_one_to_many_relation(cfg[:name], cfg[:to], + origin_key: cfg[:origin_key], origin_key_target: 'id') + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb new file mode 100644 index 000000000..549dabe6d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb @@ -0,0 +1,73 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Shared machinery for the "two-step" relation filters. Forest's native + # OneToMany navigation emits a leaf on a virtual foreign key; these filters + # pre-resolve that key against an intermediate collection and rewrite the + # predicate as `target_field IN [resolved ids]`. + # + # Centralising it here keeps the four concrete filters in sync: the + # match-nothing sentinel, the EQUAL/IN normalisation, and — crucially — + # the *paginated* intermediate read all live in one place. A single + # un-paginated `list` would silently cap resolution at one API page and + # drop matching records for large relations. + module PivotResolution + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + ConditionTreeBranch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Projection = ForestAdminDatasourceToolkit::Components::Query::Projection + Page = ForestAdminDatasourceToolkit::Components::Query::Page + + # All-zero UUID: guaranteed not to exist in Numeral, so the native list + # returns []. Expresses "match nothing" without tripping the empty-IN + # guard in ConditionTreeTranslator. + NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze + + # Only EQUAL/IN are rewritten — the operators Forest's OneToMany + # navigation actually emits. + SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze + + # Upper bound on resolved ids. The host collection walks Numeral's cursor + # pages under the hood; we ask for one large window so it fetches them all + # in O(n / page) rather than re-walking per offset. A relation resolving + # to more than this is logged rather than silently truncated. + MAX_RESOLVED = 10_000 + + module_function + + def normalize(value, operator) + values = operator == Operators::IN ? Array(value) : [value] + values.compact.reject { |v| v.to_s.empty? }.uniq + end + + def no_match(target_field) + ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) + end + + def and_branch(*leaves) + ConditionTreeBranch.new('And', leaves) + end + + # Lists every row of `collection_name` matching `condition_tree` and + # returns the unique non-empty values of `field` (handles both scalar + # columns and array columns such as InternalAccount.connected_account_ids). + # One large-window request lets the collection's cursor pagination fetch + # all matching rows in a single forward walk. + def collect(context, collection_name, condition_tree, field) + rows = context.datasource.get_collection(collection_name).list( + Filter.new(condition_tree: condition_tree, page: Page.new(offset: 0, limit: MAX_RESOLVED)), + Projection.new([field]) + ) + if rows.size >= MAX_RESOLVED + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] #{collection_name} relation resolution hit the " \ + "#{MAX_RESOLVED}-row cap on '#{field}'; results may be truncated." + ) + end + rows.flat_map { |row| Array(row[field]) }.compact.reject { |v| v.to_s.empty? }.uniq + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_connected_account_filter.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_connected_account_filter.rb new file mode 100644 index 000000000..1b3f278f9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_connected_account_filter.rb @@ -0,0 +1,38 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Two-step pre-resolution for `internal_account_id` filters on host + # collections that link to InternalAccount transitively via the array + # column `InternalAccount.connected_account_ids` (not a scalar FK). + # Resolves the holder ids to the set of connected_account ids, then + # rewrites the predicate against a real field on the host collection + # (`id` for ConnectedAccount, `connected_account_id` for resources + # scoped by connected account). + module TwoStepConnectedAccountFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + ARRAY_FIELD = 'connected_account_ids'.freeze + FK_NAME = 'internal_account_id'.freeze + + def self.install(collection_customizer, target_field:) + PivotResolution::SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(FK_NAME, operator) do |value, context| + ia_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(target_field) if ia_ids.empty? + + ca_ids = PivotResolution.collect( + context, INTERNAL_ACCOUNT, + ConditionTreeLeaf.new('id', Operators::IN, ia_ids), ARRAY_FIELD + ) + next PivotResolution.no_match(target_field) if ca_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, ca_ids) + end + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_cross_reconciliation_filter.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_cross_reconciliation_filter.rb new file mode 100644 index 000000000..dbb8720e6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_cross_reconciliation_filter.rb @@ -0,0 +1,55 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Resolves "matched payment" filters that cross the Reconciliation pivot + # twice via the shared transaction. Used when two payment resources only + # know about each other through reconciliations against the same + # Transaction (e.g. IncomingPayment <-> ExpectedPayment). + # + # Chain (for a `matched_X_id` filter installed on host collection Y): + # Reconciliation WHERE payment_id IN [x_ids] AND payment_type = src + # -> transaction_ids + # Reconciliation WHERE transaction_id IN [tx_ids] AND payment_type = dst + # -> y_ids + # The predicate is then rewritten as `target_field IN y_ids` on the host. + module TwoStepCrossReconciliationFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RECONCILIATION = 'MambuReconciliation'.freeze + + def self.install(collection_customizer, fk_name:, src_payment_type:, dst_payment_type:, target_field:) + PivotResolution::SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + src_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(target_field) if src_ids.empty? + + tx_ids = TwoStepCrossReconciliationFilter.resolve(context, 'payment_id', src_ids, + src_payment_type, 'transaction_id') + next PivotResolution.no_match(target_field) if tx_ids.empty? + + dst_ids = TwoStepCrossReconciliationFilter.resolve(context, 'transaction_id', tx_ids, + dst_payment_type, 'payment_id') + next PivotResolution.no_match(target_field) if dst_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, dst_ids) + end + end + end + + # One hop across the reconciliation pivot: rows where `where_field IN ids` + # and `payment_type = type`, projected onto `select_field`. + def self.resolve(context, where_field, ids, payment_type, select_field) + PivotResolution.collect( + context, RECONCILIATION, + PivotResolution.and_branch( + ConditionTreeLeaf.new(where_field, Operators::IN, ids), + ConditionTreeLeaf.new('payment_type', Operators::EQUAL, payment_type) + ), + select_field + ) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_holder_filter.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_holder_filter.rb new file mode 100644 index 000000000..19f7ab6f7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_holder_filter.rb @@ -0,0 +1,32 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Two-step pre-resolution for `account_holder_id` filters on host + # collections that link to AccountHolder transitively. Default + # `import_field` would emit a nested leaf the native list rejects; + # we pre-list the intermediate collection and rewrite as + # `local_fk IN (ids)`. + module TwoStepHolderFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + def self.install(collection_customizer, fk_name:, local_fk:, intermediate_collection:) + PivotResolution::SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + holder_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(local_fk) if holder_ids.empty? + + fk_ids = PivotResolution.collect( + context, intermediate_collection, + ConditionTreeLeaf.new(fk_name, Operators::IN, holder_ids), 'id' + ) + next PivotResolution.no_match(local_fk) if fk_ids.empty? + + ConditionTreeLeaf.new(local_fk, Operators::IN, fk_ids) + end + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_link_plugin.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_link_plugin.rb new file mode 100644 index 000000000..100aefecb --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_link_plugin.rb @@ -0,0 +1,64 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Base for relations whose foreign key is not native: the `filtered` + # collection gets a virtual (always-nil) FK column plus a two-step + # operator filter that rewrites EQUAL/IN predicates, and the `owner` + # collection gets the reciprocal OneToMany. Subclasses declare the shape + # and provide the concrete filter install: + # + # class LinkInternalAccountToBalances < TwoStepLinkPlugin + # link owner: 'MambuInternalAccount', filtered: 'MambuBalance', + # name: 'balances', fk: 'internal_account_id' + # def install_source_filter(collection) + # TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id') + # end + # end + # + # Install at the datasource level: @agent.use(plugin, {}). + class TwoStepLinkPlugin < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + class << self + attr_reader :config + end + + def self.link(owner:, filtered:, name:, foreign_key:) + @config = { owner: owner, filtered: filtered, name: name, fk: foreign_key } + end + + # The virtual FK is nil per record: a reverse lookup would require + # scanning the pivot/intermediate collection. Only EQUAL/IN filters are + # meaningful, and those are rewritten by the source filter. + def self.virtual_fk + ComputedDefinition.new( + column_type: 'String', + dependencies: ['id'], + values: proc { |records, _ctx| records.map { nil } } + ) + end + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) + + cfg = self.class.config + plugin = self + datasource_customizer.customize_collection(cfg[:filtered]) do |c| + c.add_field(cfg[:fk], TwoStepLinkPlugin.virtual_fk) + plugin.install_source_filter(c) + end + + datasource_customizer.customize_collection(cfg[:owner]) do |c| + c.add_one_to_many_relation(cfg[:name], cfg[:filtered], + origin_key: cfg[:fk], origin_key_target: 'id') + end + end + + # Installs the operator filter that rewrites the virtual FK predicate. + def install_source_filter(_collection) + raise NotImplementedError, "#{self.class} must implement #install_source_filter" + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_reconciliation_filter.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_reconciliation_filter.rb new file mode 100644 index 000000000..2d43d5d30 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_reconciliation_filter.rb @@ -0,0 +1,39 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Two-step pre-resolution for `payment_order_id` / `incoming_payment_id` + # / ... virtual filters on Transaction (and other resources that link to + # payments via the Reconciliation pivot, not via a native FK). + # Resolves the payment ids to the set of transaction_ids through + # `Reconciliation.payment_id` + `Reconciliation.payment_type`, then + # rewrites the predicate against the host's real id field. + module TwoStepReconciliationFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RECONCILIATION = 'MambuReconciliation'.freeze + + def self.install(collection_customizer, fk_name:, payment_type:, target_field:) + PivotResolution::SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + payment_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(target_field) if payment_ids.empty? + + tx_ids = PivotResolution.collect( + context, RECONCILIATION, + PivotResolution.and_branch( + ConditionTreeLeaf.new('payment_id', Operators::IN, payment_ids), + ConditionTreeLeaf.new('payment_type', Operators::EQUAL, payment_type) + ), + 'transaction_id' + ) + next PivotResolution.no_match(target_field) if tx_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, tx_ids) + end + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order.rb new file mode 100644 index 000000000..2cf0a05f1 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order.rb @@ -0,0 +1,56 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + # Approves a payment order in status `pending_approval`. The Numeral API + # rejects approval for orders in any other status, so per-id rescue keeps + # one bad id from aborting a bulk approval. + class ApprovePaymentOrder < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + + NAMES = { single: 'Approve Mambu payment order', + bulk: 'Approve selected Mambu payment orders' }.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'ApprovePaymentOrder plugin requires :datasource' unless datasource + raise ArgumentError, 'ApprovePaymentOrder plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'ApprovePaymentOrder plugin requires a collection' unless collection_customizer + + Helpers.normalize_scopes(options[:scopes]).each do |scope_key| + collection_customizer.add_action(NAMES[scope_key], build_action(datasource, scope_key, record_id_field)) + end + end + + private + + def build_action(datasource, scope_key, record_id_field) + BaseAction.new(scope: Helpers::SCOPES[scope_key], &executor(datasource, record_id_field)) + end + + def executor(datasource, record_id_field) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, record_id_field) + next result_builder.error(message: "No Mambu payment order id found in '#{record_id_field}'.") if ids.empty? + + succeeded, failed = Helpers.each_with_rescue(ids, 'approve_payment_order') do |id| + datasource.client.approve_payment_order(id) + end + finalize(result_builder, succeeded, failed) + end + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'payment order', + verb: 'approve')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'payment order', + verb_past: 'approved')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order.rb new file mode 100644 index 000000000..a7d37adfd --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order.rb @@ -0,0 +1,66 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + # Cancels a payment order. The optional `reason` is only used by Numeral + # for SEPA direct debit cancelations before settlement; for other payment + # types it is accepted and ignored. + class CancelPaymentOrder < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAMES = { single: 'Cancel Mambu payment order', + bulk: 'Cancel selected Mambu payment orders' }.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'CancelPaymentOrder plugin requires :datasource' unless datasource + raise ArgumentError, 'CancelPaymentOrder plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'CancelPaymentOrder plugin requires a collection' unless collection_customizer + + Helpers.normalize_scopes(options[:scopes]).each do |scope_key| + collection_customizer.add_action(NAMES[scope_key], build_action(datasource, scope_key, record_id_field)) + end + end + + private + + def build_action(datasource, scope_key, record_id_field) + BaseAction.new(scope: Helpers::SCOPES[scope_key], form: form, &executor(datasource, record_id_field)) + end + + def form + [{ type: FieldType::STRING, label: 'Reason', + description: 'Optional reason code (SEPA direct debit only).' }] + end + + def executor(datasource, record_id_field) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, record_id_field) + next result_builder.error(message: "No Mambu payment order id found in '#{record_id_field}'.") if ids.empty? + + payload = {} + reason = context.form_values['Reason'] + payload['reason'] = reason if Helpers.present?(reason) + + succeeded, failed = Helpers.each_with_rescue(ids, 'cancel_payment_order') do |id| + datasource.client.cancel_payment_order(id, payload) + end + finalize(result_builder, succeeded, failed) + end + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'payment order', + verb: 'cancel')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'payment order', + verb_past: 'canceled')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder.rb new file mode 100644 index 000000000..8ba798a2b --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder.rb @@ -0,0 +1,44 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class CreateAccountHolder < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Create Mambu account holder'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + raise ArgumentError, 'CreateAccountHolder plugin requires :datasource' unless datasource + raise ArgumentError, 'CreateAccountHolder plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [{ type: FieldType::STRING, label: 'Name', is_required: true, + description: 'Display name of the account holder.' }] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + values = context.form_values + payload = { 'name' => values['Name'] } + holder = datasource.client.create_account_holder(payload) + id = holder.is_a?(Hash) ? holder['id'] : nil + writeback = Helpers.write_back(context, opts[:result_field], id) + message = id ? "Account holder ##{id} created." : 'Account holder created.' + result_builder.success(message: "#{message}#{Helpers.write_back_warning(writeback)}") + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account.rb new file mode 100644 index 000000000..f04bc8bba --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account.rb @@ -0,0 +1,54 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class CreateExternalAccount < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Create Mambu external account'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + raise ArgumentError, 'CreateExternalAccount plugin requires :datasource' unless datasource + raise ArgumentError, 'CreateExternalAccount plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [ + { type: FieldType::STRING, label: 'Holder name', is_required: true, + description: 'Name of the legal entity or individual holding the account.' }, + { type: FieldType::STRING, label: 'Account number', is_required: true, + description: 'IBAN, UK account number, or local format.' }, + { type: FieldType::STRING, label: 'Bank code', is_required: true, + description: 'BIC, UK sort code, US routing number, or local equivalent.' } + ] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + values = context.form_values + payload = { + 'holder_name' => values['Holder name'], + 'account_number' => values['Account number'], + 'bank_code' => values['Bank code'] + } + account = datasource.client.create_external_account(payload) + id = account.is_a?(Hash) ? account['id'] : nil + writeback = Helpers.write_back(context, opts[:result_field], id) + message = id ? "External account ##{id} created." : 'External account created.' + result_builder.success(message: "#{message}#{Helpers.write_back_warning(writeback)}") + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account.rb new file mode 100644 index 000000000..70a9ebcbe --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account.rb @@ -0,0 +1,58 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class CreateInternalAccount < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Create Mambu internal account'.freeze + TYPES = %w[own virtual].freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + raise ArgumentError, 'CreateInternalAccount plugin requires :datasource' unless datasource + raise ArgumentError, 'CreateInternalAccount plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [ + { type: FieldType::ENUM, label: 'Type', is_required: true, enum_values: TYPES, + description: 'own (real bank account) or virtual (sub-account).' }, + { type: FieldType::STRING, label: 'Name', is_required: true, + description: 'Display name (max 100 characters).' }, + { type: FieldType::STRING, label: 'Holder name', is_required: true, + description: 'Account holder name (max 100 characters).' }, + { type: FieldType::STRING, label: 'Account number', is_required: true, + description: 'IBAN or local account number (own); up to 35 alnum chars (virtual).' } + ] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + values = context.form_values + payload = { + 'type' => values['Type'], + 'name' => values['Name'], + 'holder_name' => values['Holder name'], + 'account_number' => values['Account number'] + } + account = datasource.client.create_internal_account(payload) + id = account.is_a?(Hash) ? account['id'] : nil + writeback = Helpers.write_back(context, opts[:result_field], id) + message = id ? "Internal account ##{id} created." : 'Internal account created.' + result_builder.success(message: "#{message}#{Helpers.write_back_warning(writeback)}") + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order.rb new file mode 100644 index 000000000..2da9f104d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order.rb @@ -0,0 +1,66 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class CreatePaymentOrder < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Create Mambu payment order'.freeze + DIRECTIONS = %w[credit debit].freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + raise ArgumentError, 'CreatePaymentOrder plugin requires :datasource' unless datasource + raise ArgumentError, 'CreatePaymentOrder plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [ + { type: FieldType::STRING, label: 'Type', is_required: true, + description: 'Payment type (e.g. sepa_credit_transfer, swift). See Numeral docs for the full list.' }, + { type: FieldType::ENUM, label: 'Direction', is_required: true, enum_values: DIRECTIONS }, + { type: FieldType::NUMBER, label: 'Amount', is_required: true, + description: "Amount in the currency's smallest unit (e.g. cents for EUR)." }, + { type: FieldType::STRING, label: 'Currency', is_required: true, + description: 'ISO 4217 code (e.g. EUR, USD).' }, + { type: FieldType::STRING, label: 'Reference', is_required: true, + description: 'Reference shown on the account statements (max 140 characters).' }, + { type: FieldType::STRING, label: 'Connected account id', is_required: true, + description: 'UUID of the connected account that triggers the payment.' } + ] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + values = context.form_values + amount = Helpers.to_int(values['Amount']) + next result_builder.error(message: 'Amount must be an integer (smallest currency unit).') unless amount + + payload = { + 'type' => values['Type'], + 'direction' => values['Direction'], + 'amount' => amount, + 'currency' => values['Currency'], + 'reference' => values['Reference'], + 'connected_account_id' => values['Connected account id'] + } + order = datasource.client.create_payment_order(payload) + id = order.is_a?(Hash) ? order['id'] : nil + writeback = Helpers.write_back(context, opts[:result_field], id) + message = id ? "Payment order ##{id} created." : 'Payment order created.' + result_builder.success(message: "#{message}#{Helpers.write_back_warning(writeback)}") + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification.rb new file mode 100644 index 000000000..a838c85ef --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification.rb @@ -0,0 +1,58 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + # Triggers Numeral's asynchronous external-account verification (a.k.a. + # Verification of Payee / VOP). The API returns immediately with status + # `pending_verification`; the actual result lands ~30s later via webhook. + class TriggerPayeeVerification < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + + NAMES = { single: 'Trigger payee verification', + bulk: 'Trigger payee verification on selected accounts' }.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'TriggerPayeeVerification plugin requires :datasource' unless datasource + raise ArgumentError, 'TriggerPayeeVerification plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'TriggerPayeeVerification plugin requires a collection' unless collection_customizer + + Helpers.normalize_scopes(options[:scopes]).each do |scope_key| + collection_customizer.add_action(NAMES[scope_key], build_action(datasource, scope_key, record_id_field)) + end + end + + private + + def build_action(datasource, scope_key, record_id_field) + BaseAction.new(scope: Helpers::SCOPES[scope_key], &executor(datasource, record_id_field)) + end + + def executor(datasource, record_id_field) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, record_id_field) + if ids.empty? + next result_builder.error(message: "No Mambu external account id found in '#{record_id_field}'.") + end + + succeeded, failed = Helpers.each_with_rescue(ids, 'verify_external_account') do |id| + datasource.client.verify_external_account(id) + end + finalize(result_builder, succeeded, failed) + end + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'external account', + verb: 'verify')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'external account', + verb_past: 'now pending verification')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder.rb new file mode 100644 index 000000000..1737d6fed --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder.rb @@ -0,0 +1,67 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class UpdateAccountHolder < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Update Mambu account holder'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'UpdateAccountHolder plugin requires :datasource' unless datasource + raise ArgumentError, 'UpdateAccountHolder plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'UpdateAccountHolder plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [{ type: FieldType::STRING, label: 'Name', + description: 'New display name (leave empty to keep the current value).' }] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, opts[:record_id_field]) + if ids.empty? + next result_builder.error(message: "No Mambu account holder id found in '#{opts[:record_id_field]}'.") + end + + payload = build_payload(context.form_values) + next result_builder.error(message: 'Nothing to update: fill at least one field.') if payload.empty? + + succeeded, failed = Helpers.each_with_rescue(ids, 'update_account_holder') do |id| + datasource.client.update_account_holder(id, payload) + end + finalize(result_builder, succeeded, failed) + end + end + + def build_payload(values) + payload = {} + payload['name'] = values['Name'] if Helpers.present?(values['Name']) + payload + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'account holder', + verb: 'update')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'account holder', + verb_past: 'updated')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account.rb new file mode 100644 index 000000000..3f33010db --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account.rb @@ -0,0 +1,75 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class UpdateExternalAccount < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Update Mambu external account'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'UpdateExternalAccount plugin requires :datasource' unless datasource + raise ArgumentError, 'UpdateExternalAccount plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'UpdateExternalAccount plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [ + { type: FieldType::STRING, label: 'Holder name', + description: 'Leave empty to keep the current value.' }, + { type: FieldType::STRING, label: 'Account number', + description: 'Leave empty to keep the current value.' }, + { type: FieldType::STRING, label: 'Bank code', + description: 'Leave empty to keep the current value.' } + ] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, opts[:record_id_field]) + if ids.empty? + next result_builder.error(message: "No Mambu external account id found in '#{opts[:record_id_field]}'.") + end + + payload = build_payload(context.form_values) + next result_builder.error(message: 'Nothing to update: fill at least one field.') if payload.empty? + + succeeded, failed = Helpers.each_with_rescue(ids, 'update_external_account') do |id| + datasource.client.update_external_account(id, payload) + end + finalize(result_builder, succeeded, failed) + end + end + + def build_payload(values) + payload = {} + payload['holder_name'] = values['Holder name'] if Helpers.present?(values['Holder name']) + payload['account_number'] = values['Account number'] if Helpers.present?(values['Account number']) + payload['bank_code'] = values['Bank code'] if Helpers.present?(values['Bank code']) + payload + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'external account', + verb: 'update')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'external account', + verb_past: 'updated')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account.rb new file mode 100644 index 000000000..3df4c33f9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account.rb @@ -0,0 +1,75 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module SmartActions + class UpdateInternalAccount < ForestAdminDatasourceCustomizer::Plugins::Plugin + BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction + ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope + FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType + + NAME = 'Update Mambu internal account'.freeze + + def run(_datasource_customizer, collection_customizer = nil, options = {}) + datasource = options[:datasource] + record_id_field = options[:record_id_field] + raise ArgumentError, 'UpdateInternalAccount plugin requires :datasource' unless datasource + raise ArgumentError, 'UpdateInternalAccount plugin requires :record_id_field' unless record_id_field + raise ArgumentError, 'UpdateInternalAccount plugin requires a collection' unless collection_customizer + + collection_customizer.add_action(options[:action_name] || NAME, build_action(datasource, options)) + end + + private + + def build_action(datasource, opts) + BaseAction.new(scope: ActionScope::SINGLE, form: form, &executor(datasource, opts)) + end + + def form + [ + { type: FieldType::STRING, label: 'Name', + description: 'Leave empty to keep the current value.' }, + { type: FieldType::STRING, label: 'Holder name', + description: 'Leave empty to keep the current value.' }, + { type: FieldType::STRING, label: 'Account number', + description: 'Leave empty to keep the current value.' } + ] + end + + def executor(datasource, opts) + lambda do |context, result_builder| + ids = Helpers.resolve_ids(context, opts[:record_id_field]) + if ids.empty? + next result_builder.error(message: "No Mambu internal account id found in '#{opts[:record_id_field]}'.") + end + + payload = build_payload(context.form_values) + next result_builder.error(message: 'Nothing to update: fill at least one field.') if payload.empty? + + succeeded, failed = Helpers.each_with_rescue(ids, 'update_internal_account') do |id| + datasource.client.update_internal_account(id, payload) + end + finalize(result_builder, succeeded, failed) + end + end + + def build_payload(values) + payload = {} + payload['name'] = values['Name'] if Helpers.present?(values['Name']) + payload['holder_name'] = values['Holder name'] if Helpers.present?(values['Holder name']) + payload['account_number'] = values['Account number'] if Helpers.present?(values['Account number']) + payload + end + + def finalize(result_builder, succeeded, failed) + if succeeded.empty? + return result_builder.error(message: Messages.all_failed(failed, noun: 'internal account', + verb: 'update')) + end + + result_builder.success(message: Messages.success(succeeded, failed, noun: 'internal account', + verb_past: 'updated')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/query/condition_tree_translator.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/query/condition_tree_translator.rb new file mode 100644 index 000000000..a2afb27be --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/query/condition_tree_translator.rb @@ -0,0 +1,115 @@ +module ForestAdminDatasourceMambuPayments + module Query + # Translates a Forest condition tree into a hash of Numeral query params. + # + # The previous version of `fetch_records` short-circuited on `id` and + # otherwise sent an unfiltered list — silently producing wrong counts and + # missing rows whenever the UI applied a non-id predicate. This translator + # raises `UnsupportedOperatorError` for anything it cannot map, so the + # failure mode is "loud error" rather than "wrong data". + # + # Each collection declares its server-filterable fields via `api_filters`: + # + # { 'connected_account_id' => { ops: [EQUAL, IN], param: 'connected_account_id' } } + # + # `param` defaults to the field name. EQUAL emits a scalar value, IN emits + # an Array (the client joins arrays with commas — see `Client#normalize_params`). + # Top-level OR aggregation is rejected: Numeral list endpoints have no + # general OR support, so silently translating "A or B" to "A and B" would + # be wrong in both directions. + class ConditionTreeTranslator + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + def self.call(condition_tree, api_filters: {}) + return {} if condition_tree.nil? + + new(api_filters).translate(condition_tree) + end + + def initialize(api_filters) + @api_filters = api_filters || {} + end + + def translate(node) + case node + when Branch then translate_branch(node) + when Leaf then translate_leaf(node) + else + raise UnsupportedOperatorError, "Unknown condition node: #{node.class}" + end + end + + private + + def translate_branch(branch) + unless branch.aggregator.to_s.casecmp('and').zero? + raise UnsupportedOperatorError, + "Mambu Payments list endpoints do not support OR aggregation (got #{branch.aggregator.inspect}). " \ + 'Split the request into separate filters.' + end + + branch.conditions.each_with_object({}) do |condition, acc| + translate(condition).each do |key, value| + if acc.key?(key) + raise UnsupportedOperatorError, + "Conflicting predicates on '#{key}': cannot pass the same query param twice." + end + + acc[key] = value + end + end + end + + def translate_leaf(leaf) + spec = @api_filters[leaf.field] + unless spec + raise UnsupportedOperatorError, + "Mambu Payments datasource does not yet translate filters on '#{leaf.field}'. " \ + 'Add it to the collection\'s `api_filters` after verifying the Numeral docs.' + end + + param = (spec[:param] || leaf.field).to_s + ops = Array(spec[:ops]) + unless ops.include?(leaf.operator) + raise UnsupportedOperatorError, + "Operator '#{leaf.operator}' is not supported on field '#{leaf.field}'. " \ + "Supported: #{ops.join(", ")}." + end + + translate_value(param, leaf.operator, leaf.value) + end + + def translate_value(param, operator, value) + case operator + when Operators::EQUAL + raise_nil_value(param) if value.nil? + { param => value } + when Operators::IN + values = Array(value).reject { |v| v.nil? || v.to_s.empty? } + raise_empty_in(param) if values.empty? + { param => values } + else + raise UnsupportedOperatorError, + "Operator '#{operator}' is declared in api_filters but has no translation rule." + end + end + + # `field=` with a nil value would semantically degrade to "filter present" on + # most REST APIs — silently the wrong query. Use PRESENT / BLANK instead + # (once those operators are wired up here). + def raise_nil_value(param) + raise UnsupportedOperatorError, + "Filter value on '#{param}' is nil; the PRESENT / BLANK operators are not yet supported." + end + + # An empty `IN []` would translate to no params, silently turning + # "match nothing" into "match everything". Raise instead. + def raise_empty_in(param) + raise UnsupportedOperatorError, + "IN on '#{param}' was given an empty array; pass at least one value." + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb new file mode 100644 index 000000000..a45254c1e --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb @@ -0,0 +1,3 @@ +module ForestAdminDatasourceMambuPayments + VERSION = "0.1.0" +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/client_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/client_spec.rb new file mode 100644 index 000000000..455c54354 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/client_spec.rb @@ -0,0 +1,473 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Client do + let(:configuration) { ForestAdminDatasourceMambuPayments::Configuration.new(api_key: 'k') } + let(:client) { described_class.new(configuration) } + let(:base) { "#{configuration.base_url}/v1" } + + def json(payload, status = 200) + { status: status, body: payload.is_a?(String) ? payload : payload.to_json, + headers: { 'Content-Type' => 'application/json' } } + end + + describe 'authentication' do + it 'sends the api key in the x-api-key header (no Bearer prefix)' do + stub_request(:get, "#{base}/connected_accounts") + .with(headers: { 'x-api-key' => 'k' }) + .to_return(json('records' => [])) + + client.list_connected_accounts + expect(WebMock).to have_requested(:get, "#{base}/connected_accounts") + .with(headers: { 'x-api-key' => 'k' }) + end + end + + describe '#list_connected_accounts' do + it 'returns the array under the "records" wrapper' do + stub_request(:get, "#{base}/connected_accounts") + .to_return(json('records' => [{ 'id' => 'a' }, { 'id' => 'b' }])) + + expect(client.list_connected_accounts.map { |r| r['id'] }).to eq(%w[a b]) + end + + it 'also accepts a "data" wrapper' do + stub_request(:get, "#{base}/connected_accounts") + .to_return(json('data' => [{ 'id' => 'a' }])) + + expect(client.list_connected_accounts.size).to eq(1) + end + + it 'accepts an array body directly' do + stub_request(:get, "#{base}/connected_accounts") + .to_return(json([{ 'id' => 'a' }])) + + expect(client.list_connected_accounts.size).to eq(1) + end + + it 'falls back to the first array-valued field when the wrapper key is unknown' do + stub_request(:get, "#{base}/connected_accounts") + .to_return(json('accounts' => [{ 'id' => 'a' }], 'total' => 1)) + + expect(client.list_connected_accounts.size).to eq(1) + end + + it 'returns [] when the body is a Hash with no array values' do + stub_request(:get, "#{base}/connected_accounts").to_return(json('total' => 0)) + expect(client.list_connected_accounts).to eq([]) + end + + it 'forwards query params, joining arrays with commas' do + stub_request(:get, "#{base}/connected_accounts") + .with(query: { 'limit' => '10', 'ids' => 'a,b' }) + .to_return(json('records' => [])) + + client.list_connected_accounts(limit: 10, ids: %w[a b]) + expect(WebMock).to have_requested(:get, "#{base}/connected_accounts") + .with(query: hash_including('limit' => '10', 'ids' => 'a,b')) + end + + it 'drops nil params before sending' do + stub_request(:get, "#{base}/connected_accounts") + .with(query: { 'limit' => '5' }) + .to_return(json('records' => [])) + + client.list_connected_accounts(limit: 5, cursor: nil) + expect(WebMock).to have_requested(:get, "#{base}/connected_accounts") + .with(query: { 'limit' => '5' }) + end + + it 'raises APIError on 5xx' do + stub_request(:get, "#{base}/connected_accounts").to_return(status: 500, body: 'boom') + expect { client.list_connected_accounts } + .to raise_error(ForestAdminDatasourceMambuPayments::APIError, /list\(connected_accounts\)/) + end + end + + describe '#find_connected_account' do + it 'returns the record directly when the body is not wrapped' do + stub_request(:get, "#{base}/connected_accounts/abc") + .to_return(json('id' => 'abc', 'name' => 'Acme')) + + expect(client.find_connected_account('abc')).to include('id' => 'abc', 'name' => 'Acme') + end + + it 'unwraps a top-level "data" hash' do + stub_request(:get, "#{base}/connected_accounts/abc") + .to_return(json('data' => { 'id' => 'abc' })) + + expect(client.find_connected_account('abc')).to eq('id' => 'abc') + end + + it 'returns nil on 404' do + stub_request(:get, "#{base}/connected_accounts/xyz").to_return(status: 404, body: '{}') + expect(client.find_connected_account('xyz')).to be_nil + end + + it 'raises APIError on other failures' do + stub_request(:get, "#{base}/connected_accounts/xyz").to_return(status: 500, body: 'boom') + expect { client.find_connected_account('xyz') } + .to raise_error(ForestAdminDatasourceMambuPayments::APIError, /get\(connected_accounts/) + end + end + + describe 'payment_orders / transactions / balances' do + it 'list_payment_orders hits /payment_orders' do + stub_request(:get, "#{base}/payment_orders").to_return(json('records' => [])) + client.list_payment_orders + expect(WebMock).to have_requested(:get, "#{base}/payment_orders") + end + + it 'find_payment_order hits /payment_orders/:id' do + stub_request(:get, "#{base}/payment_orders/po1").to_return(json('id' => 'po1')) + expect(client.find_payment_order('po1')).to include('id' => 'po1') + end + + it 'list_transactions hits /transactions' do + stub_request(:get, "#{base}/transactions").to_return(json('records' => [])) + client.list_transactions + expect(WebMock).to have_requested(:get, "#{base}/transactions") + end + + it 'find_transaction hits /transactions/:id' do + stub_request(:get, "#{base}/transactions/tx1").to_return(json('id' => 'tx1')) + expect(client.find_transaction('tx1')).to include('id' => 'tx1') + end + + it 'list_balances hits /balances' do + stub_request(:get, "#{base}/balances").to_return(json('records' => [])) + client.list_balances + expect(WebMock).to have_requested(:get, "#{base}/balances") + end + + it 'find_balance hits /balances/:id' do + stub_request(:get, "#{base}/balances/bal1").to_return(json('id' => 'bal1')) + expect(client.find_balance('bal1')).to include('id' => 'bal1') + end + + it 'create_payment_order POSTs to /payment_orders' do + stub_request(:post, "#{base}/payment_orders").to_return(json('id' => 'po1')) + expect(client.create_payment_order({})).to include('id' => 'po1') + end + + it 'update_payment_order PATCHes /payment_orders/:id' do + stub_request(:patch, "#{base}/payment_orders/po1").to_return(json('id' => 'po1')) + expect(client.update_payment_order('po1', {})).to include('id' => 'po1') + end + + it 'delete_payment_order DELETEs /payment_orders/:id' do + stub_request(:delete, "#{base}/payment_orders/po1").to_return(status: 204, body: '') + expect(client.delete_payment_order('po1')).to be(true) + end + end + + describe 'account_holders' do + it 'list_account_holders hits /account_holders' do + stub_request(:get, "#{base}/account_holders").to_return(json('records' => [])) + client.list_account_holders + expect(WebMock).to have_requested(:get, "#{base}/account_holders") + end + + it 'find_account_holder hits /account_holders/:id' do + stub_request(:get, "#{base}/account_holders/ah1").to_return(json('id' => 'ah1')) + expect(client.find_account_holder('ah1')).to include('id' => 'ah1') + end + + it 'create_account_holder POSTs to /account_holders' do + stub_request(:post, "#{base}/account_holders").to_return(json('id' => 'ah1')) + expect(client.create_account_holder({})).to include('id' => 'ah1') + end + + it 'update_account_holder PATCHes /account_holders/:id' do + stub_request(:patch, "#{base}/account_holders/ah1").to_return(json('id' => 'ah1')) + expect(client.update_account_holder('ah1', {})).to include('id' => 'ah1') + end + + it 'delete_account_holder DELETEs /account_holders/:id' do + stub_request(:delete, "#{base}/account_holders/ah1").to_return(status: 204, body: '') + expect(client.delete_account_holder('ah1')).to be(true) + end + end + + describe 'external_accounts' do + it 'list_external_accounts hits /external_accounts' do + stub_request(:get, "#{base}/external_accounts").to_return(json('records' => [])) + client.list_external_accounts + expect(WebMock).to have_requested(:get, "#{base}/external_accounts") + end + + it 'find_external_account hits /external_accounts/:id' do + stub_request(:get, "#{base}/external_accounts/ea1").to_return(json('id' => 'ea1')) + expect(client.find_external_account('ea1')).to include('id' => 'ea1') + end + + it 'create_external_account POSTs to /external_accounts' do + stub_request(:post, "#{base}/external_accounts").to_return(json('id' => 'ea1')) + expect(client.create_external_account({})).to include('id' => 'ea1') + end + + it 'update_external_account PATCHes /external_accounts/:id' do + stub_request(:patch, "#{base}/external_accounts/ea1").to_return(json('id' => 'ea1')) + expect(client.update_external_account('ea1', {})).to include('id' => 'ea1') + end + + it 'delete_external_account DELETEs /external_accounts/:id' do + stub_request(:delete, "#{base}/external_accounts/ea1").to_return(status: 204, body: '') + expect(client.delete_external_account('ea1')).to be(true) + end + end + + describe 'internal_accounts' do + it 'list_internal_accounts hits /internal_accounts' do + stub_request(:get, "#{base}/internal_accounts").to_return(json('records' => [])) + client.list_internal_accounts + expect(WebMock).to have_requested(:get, "#{base}/internal_accounts") + end + + it 'find_internal_account hits /internal_accounts/:id' do + stub_request(:get, "#{base}/internal_accounts/ia1").to_return(json('id' => 'ia1')) + expect(client.find_internal_account('ia1')).to include('id' => 'ia1') + end + + it 'create_internal_account POSTs to /internal_accounts' do + stub_request(:post, "#{base}/internal_accounts").to_return(json('id' => 'ia1')) + expect(client.create_internal_account({})).to include('id' => 'ia1') + end + + it 'update_internal_account PATCHes /internal_accounts/:id' do + stub_request(:patch, "#{base}/internal_accounts/ia1").to_return(json('id' => 'ia1')) + expect(client.update_internal_account('ia1', {})).to include('id' => 'ia1') + end + + it 'delete_internal_account DELETEs /internal_accounts/:id' do + stub_request(:delete, "#{base}/internal_accounts/ia1").to_return(status: 204, body: '') + expect(client.delete_internal_account('ia1')).to be(true) + end + end + + describe 'incoming_payments' do + it 'list_incoming_payments hits /incoming_payments' do + stub_request(:get, "#{base}/incoming_payments").to_return(json('records' => [])) + client.list_incoming_payments + expect(WebMock).to have_requested(:get, "#{base}/incoming_payments") + end + + it 'find_incoming_payment hits /incoming_payments/:id' do + stub_request(:get, "#{base}/incoming_payments/ip1").to_return(json('id' => 'ip1')) + expect(client.find_incoming_payment('ip1')).to include('id' => 'ip1') + end + end + + describe 'direct_debit_mandates' do + it 'list_direct_debit_mandates hits /direct_debit_mandates' do + stub_request(:get, "#{base}/direct_debit_mandates").to_return(json('records' => [])) + client.list_direct_debit_mandates + expect(WebMock).to have_requested(:get, "#{base}/direct_debit_mandates") + end + + it 'find_direct_debit_mandate hits /direct_debit_mandates/:id' do + stub_request(:get, "#{base}/direct_debit_mandates/dm1").to_return(json('id' => 'dm1')) + expect(client.find_direct_debit_mandate('dm1')).to include('id' => 'dm1') + end + + it 'create_direct_debit_mandate POSTs to /direct_debit_mandates' do + stub_request(:post, "#{base}/direct_debit_mandates").to_return(json('id' => 'dm1')) + expect(client.create_direct_debit_mandate({})).to include('id' => 'dm1') + end + + it 'update_direct_debit_mandate PATCHes /direct_debit_mandates/:id' do + stub_request(:patch, "#{base}/direct_debit_mandates/dm1").to_return(json('id' => 'dm1')) + expect(client.update_direct_debit_mandate('dm1', {})).to include('id' => 'dm1') + end + + it 'delete_direct_debit_mandate DELETEs /direct_debit_mandates/:id' do + stub_request(:delete, "#{base}/direct_debit_mandates/dm1").to_return(status: 204, body: '') + expect(client.delete_direct_debit_mandate('dm1')).to be(true) + end + end + + describe 'expected_payments' do + it 'list_expected_payments hits /expected_payments' do + stub_request(:get, "#{base}/expected_payments").to_return(json('records' => [])) + client.list_expected_payments + expect(WebMock).to have_requested(:get, "#{base}/expected_payments") + end + + it 'find_expected_payment hits /expected_payments/:id' do + stub_request(:get, "#{base}/expected_payments/ep1").to_return(json('id' => 'ep1')) + expect(client.find_expected_payment('ep1')).to include('id' => 'ep1') + end + + it 'create_expected_payment POSTs to /expected_payments' do + stub_request(:post, "#{base}/expected_payments").to_return(json('id' => 'ep1')) + expect(client.create_expected_payment({})).to include('id' => 'ep1') + end + + it 'update_expected_payment PATCHes /expected_payments/:id' do + stub_request(:patch, "#{base}/expected_payments/ep1").to_return(json('id' => 'ep1')) + expect(client.update_expected_payment('ep1', {})).to include('id' => 'ep1') + end + + it 'delete_expected_payment DELETEs /expected_payments/:id' do + stub_request(:delete, "#{base}/expected_payments/ep1").to_return(status: 204, body: '') + expect(client.delete_expected_payment('ep1')).to be(true) + end + end + + describe 'events' do + it 'list_events hits /events' do + stub_request(:get, "#{base}/events").to_return(json('records' => [])) + client.list_events + expect(WebMock).to have_requested(:get, "#{base}/events") + end + + it 'find_event hits /events/:id' do + stub_request(:get, "#{base}/events/ev1").to_return(json('id' => 'ev1')) + expect(client.find_event('ev1')).to include('id' => 'ev1') + end + end + + describe 'files' do + it 'list_files hits /files' do + stub_request(:get, "#{base}/files").to_return(json('records' => [])) + client.list_files + expect(WebMock).to have_requested(:get, "#{base}/files") + end + + it 'find_file hits /files/:id' do + stub_request(:get, "#{base}/files/f1").to_return(json('id' => 'f1')) + expect(client.find_file('f1')).to include('id' => 'f1') + end + end + + describe 'reconciliations' do + it 'list_reconciliations hits /reconciliations' do + stub_request(:get, "#{base}/reconciliations").to_return(json('records' => [])) + client.list_reconciliations + expect(WebMock).to have_requested(:get, "#{base}/reconciliations") + end + + it 'find_reconciliation hits /reconciliations/:id' do + stub_request(:get, "#{base}/reconciliations/rec1").to_return(json('id' => 'rec1')) + expect(client.find_reconciliation('rec1')).to include('id' => 'rec1') + end + + it 'create_reconciliation POSTs to /reconciliations' do + stub_request(:post, "#{base}/reconciliations").to_return(json('id' => 'rec1')) + expect(client.create_reconciliation({})).to include('id' => 'rec1') + end + + it 'update_reconciliation PATCHes /reconciliations/:id' do + stub_request(:patch, "#{base}/reconciliations/rec1").to_return(json('id' => 'rec1')) + expect(client.update_reconciliation('rec1', {})).to include('id' => 'rec1') + end + end + + describe 'claims' do + it 'list_claims hits /claims' do + stub_request(:get, "#{base}/claims").to_return(json('records' => [])) + client.list_claims + expect(WebMock).to have_requested(:get, "#{base}/claims") + end + + it 'find_claim hits /claims/:id' do + stub_request(:get, "#{base}/claims/clm1").to_return(json('id' => 'clm1')) + expect(client.find_claim('clm1')).to include('id' => 'clm1') + end + end + + describe 'payment_captures' do + it 'list_payment_captures hits /payment_captures' do + stub_request(:get, "#{base}/payment_captures").to_return(json('records' => [])) + client.list_payment_captures + expect(WebMock).to have_requested(:get, "#{base}/payment_captures") + end + + it 'find_payment_capture hits /payment_captures/:id' do + stub_request(:get, "#{base}/payment_captures/pc1").to_return(json('id' => 'pc1')) + expect(client.find_payment_capture('pc1')).to include('id' => 'pc1') + end + end + + describe 'payee_verification_requests' do + it 'list_payee_verification_requests hits /payee_verification_requests' do + stub_request(:get, "#{base}/payee_verification_requests").to_return(json('records' => [])) + client.list_payee_verification_requests + expect(WebMock).to have_requested(:get, "#{base}/payee_verification_requests") + end + + it 'find_payee_verification_request hits /payee_verification_requests/:id' do + stub_request(:get, "#{base}/payee_verification_requests/pvr1").to_return(json('id' => 'pvr1')) + expect(client.find_payee_verification_request('pvr1')).to include('id' => 'pvr1') + end + end + + describe 'returns' do + it 'list_returns hits /returns' do + stub_request(:get, "#{base}/returns").to_return(json('records' => [])) + client.list_returns + expect(WebMock).to have_requested(:get, "#{base}/returns") + end + + it 'find_return hits /returns/:id' do + stub_request(:get, "#{base}/returns/ret1").to_return(json('id' => 'ret1')) + expect(client.find_return('ret1')).to include('id' => 'ret1') + end + + it 'create_return POSTs to /returns' do + stub_request(:post, "#{base}/returns").to_return(json('id' => 'ret1')) + expect(client.create_return({})).to include('id' => 'ret1') + end + + it 'update_return PATCHes /returns/:id' do + stub_request(:patch, "#{base}/returns/ret1").to_return(json('id' => 'ret1')) + expect(client.update_return('ret1', {})).to include('id' => 'ret1') + end + end + + describe 'action endpoints (approve/cancel/verify)' do + it 'approve_payment_order POSTs to /payment_orders/:id/approve' do + stub_request(:post, "#{base}/payment_orders/po_1/approve").to_return(json('id' => 'po_1', 'status' => 'approved')) + expect(client.approve_payment_order('po_1')).to include('id' => 'po_1', 'status' => 'approved') + end + + it 'cancel_payment_order POSTs to /payment_orders/:id/cancel with the reason' do + stub_request(:post, "#{base}/payment_orders/po_1/cancel") + .with(body: { reason: 'AC01' }.to_json) + .to_return(json('id' => 'po_1', 'status' => 'canceled')) + expect(client.cancel_payment_order('po_1', 'reason' => 'AC01')).to include('status' => 'canceled') + end + + it 'verify_external_account POSTs to /external_accounts/:id/verify' do + stub_request(:post, "#{base}/external_accounts/ea_1/verify") + .to_return(json('id' => 'ea_1', 'status' => 'pending_verification')) + expect(client.verify_external_account('ea_1')).to include('status' => 'pending_verification') + end + + it 'wraps action endpoint errors with the operation name' do + stub_request(:post, "#{base}/payment_orders/bad/approve").to_return(status: 422, body: '{}') + expect { client.approve_payment_order('bad') } + .to raise_error(ForestAdminDatasourceMambuPayments::APIError, %r{approve\(payment_orders/bad\)}) + end + end + + describe 'structured API errors' do + it 'surfaces the HTTP status and the Numeral error message on a 422' do + stub_request(:post, "#{base}/payment_orders") + .to_return(status: 422, + body: { 'error' => { 'message' => 'iban is invalid' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect { client.create_payment_order({}) } + .to raise_error(ForestAdminDatasourceMambuPayments::APIError, /HTTP 422.*iban is invalid/) + end + + it 'exposes the status and parsed body on the error object' do + stub_request(:post, "#{base}/payment_orders") + .to_return(status: 422, body: { 'message' => 'nope' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + client.create_payment_order({}) + rescue ForestAdminDatasourceMambuPayments::APIError => e + expect(e.status).to eq(422) + expect(e.body).to eq('message' => 'nope') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/account_holder_spec.rb new file mode 100644 index 000000000..b017f82fc --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/account_holder_spec.rb @@ -0,0 +1,122 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::AccountHolder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:collection) { described_class.new(datasource) } + + let(:holder) do + { + 'id' => '019e1655-bfa8-75bc-b5b8-06c144903273', + 'object' => 'account_holder', + 'name' => 'Account Holder Test Christophe', + 'metadata' => {}, + 'created_at' => '2026-05-11T09:19:38Z', + 'disabled_at' => nil + } + end + + describe 'schema' do + it 'declares the 6 API-aligned columns and the two OneToMany inverses' do + expect(collection.schema[:fields].keys).to contain_exactly( + 'id', 'object', 'name', 'metadata', 'disabled_at', 'created_at', + 'external_accounts', 'internal_accounts' + ) + end + + it 'exposes external_accounts and internal_accounts as OneToMany relations' do + f = collection.schema[:fields] + expect(f['external_accounts']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + expect(f['internal_accounts']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + end + + it 'marks system-managed fields read-only' do + f = collection.schema[:fields] + %w[id object disabled_at created_at].each { |k| expect(f[k].is_read_only).to be(true) } + end + + it 'leaves name and metadata writable' do + f = collection.schema[:fields] + expect(f['name'].is_read_only).to be(false) + expect(f['metadata'].is_read_only).to be(false) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash' do + expect(collection.serialize(holder)).to include( + 'id' => holder['id'], 'name' => holder['name'], 'metadata' => {} + ) + end + end + + describe '#list' do + it 'short-circuits to find_account_holder on id lookup' do + allow(client).to receive(:find_account_holder).with(holder['id']).and_return(holder) + allow(client).to receive(:list_account_holders) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', holder['id'])) + rows = collection.list(nil, filter, nil) + + expect(rows.first['name']).to eq(holder['name']) + expect(client).not_to have_received(:list_account_holders) + end + + it 'falls back to a paginated list when there is no id filter' do + allow(client).to receive(:list_account_holders).and_return([holder]) + + rows = collection.list(nil, Filter.new, %w[id name]) + + expect(rows).to eq([{ 'id' => holder['id'], 'name' => holder['name'] }]) + expect(client).to have_received(:list_account_holders).with(limit: Client::MAX_PER_PAGE) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_account_holder) do |payload| + expect(payload).to include('name' => 'New') + expect(payload.keys).not_to include('id', 'object', 'created_at', 'disabled_at') + { 'id' => 'new', 'name' => 'New' } + end + + result = collection.create(nil, + 'id' => 'ignored', 'object' => 'account_holder', + 'created_at' => 't', 'disabled_at' => nil, + 'name' => 'New') + expect(result['id']).to eq('new') + end + end + + describe '#update' do + it 'PATCHes every id resolved by the filter' do + allow(client).to receive(:find_account_holder).with('a').and_return('id' => 'a') + allow(client).to receive(:find_account_holder).with('b').and_return('id' => 'b') + allow(client).to receive(:update_account_holder) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'name' => 'Renamed') + + expect(client).to have_received(:update_account_holder).with('a', hash_including('name' => 'Renamed')) + expect(client).to have_received(:update_account_holder).with('b', hash_including('name' => 'Renamed')) + end + end + + describe '#delete' do + it 'DELETEs every id resolved by the filter' do + allow(client).to receive(:find_account_holder).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_account_holder) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_account_holder).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/balance_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/balance_spec.rb new file mode 100644 index 000000000..160236802 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/balance_spec.rb @@ -0,0 +1,88 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Balance do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:balance) do + { + 'id' => 'bal1', 'object' => 'balance', + 'connected_account_id' => 'acc1', + 'type' => 'closing_available', 'direction' => 'credit', + 'amount' => 10_000, 'currency' => 'EUR', + 'date' => '2026-05-11', + 'bank_data' => { 'file_id' => 'f1', 'statement_id' => 's1' }, + 'created_at' => '2026-05-11T07:06:09Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'type', 'direction', 'amount', 'currency', + 'date', 'bank_data', 'created_at' + ) + end + + it 'does not expose the removed as_of_date / updated_at fields' do + keys = collection.schema[:fields].keys + expect(keys).not_to include('as_of_date', 'updated_at') + end + + it 'declares only the connected_account ManyToOne relation' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account') + end + + it 'leaves `type` as a String column (Numeral has more values than booked/available/expected)' do + expect(collection.schema[:fields]['type'].column_type).to eq('String') + end + + it 'does not implement create / update / delete (balances are read-only)' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#list' do + it 'embeds connected_account when requested by the projection' do + allow(client).to receive(:list_balances).and_return([balance]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'returns the projected columns when no relation is requested' do + allow(client).to receive(:list_balances).and_return([balance]) + rows = collection.list(nil, Filter.new, %w[id amount currency]) + expect(rows.first).to eq('id' => 'bal1', 'amount' => 10_000, 'currency' => 'EUR') + end + + it 'short-circuits to find_balance on id lookup' do + allow(client).to receive(:find_balance).with('bal1').and_return(balance) + allow(client).to receive(:list_balances) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'bal1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_balance).with('bal1') + expect(client).not_to have_received(:list_balances) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/base_collection_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/base_collection_spec.rb new file mode 100644 index 000000000..0963d2da8 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/base_collection_spec.rb @@ -0,0 +1,240 @@ +module ForestAdminDatasourceMambuPayments + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + + RSpec.describe Collections::BaseCollection do + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource) } + let(:collection) { Collections::ConnectedAccount.new(datasource) } + + describe '#extract_id_lookup' do + it 'returns [value] for an id EQUAL leaf' do + leaf = Leaf.new('id', 'equal', 'a') + expect(collection.send(:extract_id_lookup, leaf)).to eq(['a']) + end + + it 'returns the array for an id IN leaf' do + leaf = Leaf.new('id', 'in', %w[a b]) + expect(collection.send(:extract_id_lookup, leaf)).to eq(%w[a b]) + end + + it 'wraps a scalar IN value into an array' do + leaf = Leaf.new('id', 'in', 'a') + expect(collection.send(:extract_id_lookup, leaf)).to eq(['a']) + end + + it 'returns nil for a leaf on another field' do + leaf = Leaf.new('name', 'equal', 'Acme') + expect(collection.send(:extract_id_lookup, leaf)).to be_nil + end + + it 'returns nil for an id leaf using an unsupported operator' do + leaf = Leaf.new('id', 'present') + expect(collection.send(:extract_id_lookup, leaf)).to be_nil + end + + it 'returns nil for a Branch node (AND/OR not unwrapped)' do + branch = Branch.new('And', [Leaf.new('id', 'equal', 'a')]) + expect(collection.send(:extract_id_lookup, branch)).to be_nil + end + + it 'returns nil for a nil node' do + expect(collection.send(:extract_id_lookup, nil)).to be_nil + end + end + + describe '#project' do + let(:record) { { 'id' => '1', 'name' => 'Acme', 'extra' => 'x' } } + + it 'returns the record unchanged when projection is nil' do + expect(collection.send(:project, record, nil)).to eq(record) + end + + it 'keeps only the requested column fields' do + result = collection.send(:project, record, %w[id name]) + expect(result).to eq('id' => '1', 'name' => 'Acme') + end + + it 'drops projection entries containing a colon (relation prefixes)' do + result = collection.send(:project, record, ['id', 'connected_account:name']) + expect(result).to eq('id' => '1') + end + + it 'returns an empty row when projection has only relation prefixes' do + # Relations are populated by embed_relations; the scalar projection is + # empty here, and returning the full record would leak unrequested columns. + expect(collection.send(:project, record, ['connected_account:name'])).to eq({}) + end + end + + describe '#paginate (Numeral cursor pagination)' do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:page_class) { ForestAdminDatasourceToolkit::Components::Query::Page } + + before { allow(datasource).to receive(:client).and_return(client) } + + it 'fetches a single page sized to the request when it fits in one window' do + allow(client).to receive(:list_connected_accounts).with(limit: 15).and_return(Array.new(15) { {} }) + + collection.send(:paginate, page_class.new(offset: 0, limit: 15), {}) + + expect(client).to have_received(:list_connected_accounts).with(limit: 15).once + end + + it 'never asks Numeral for more than MAX_PER_PAGE in one call' do + first = Array.new(100) { |i| { 'id' => "a#{i}" } } + second = Array.new(50) { |i| { 'id' => "b#{i}" } } + allow(client).to receive(:list_connected_accounts).with(limit: 100).and_return(first) + allow(client).to receive(:list_connected_accounts).with(limit: 50, starting_after: 'a99').and_return(second) + + rows = collection.send(:paginate, page_class.new(offset: 0, limit: 150), {}) + + expect(rows.size).to eq(150) + expect(rows.last['id']).to eq('b49') + end + + it 'walks the cursor to reach a non-zero offset and slices the window' do + first = Array.new(100) { |i| { 'id' => "a#{i}" } } + second = Array.new(20) { |i| { 'id' => "b#{i}" } } + allow(client).to receive(:list_connected_accounts).with(limit: 100).and_return(first) + allow(client).to receive(:list_connected_accounts).with(limit: 20, starting_after: 'a99').and_return(second) + + rows = collection.send(:paginate, page_class.new(offset: 110, limit: 10), {}) + + expect(rows.map { |r| r['id'] }).to eq(%w[b10 b11 b12 b13 b14 b15 b16 b17 b18 b19]) + end + + it 'defaults to one MAX_PER_PAGE window when no page is given' do + allow(client).to receive(:list_connected_accounts).with(limit: 100).and_return([]) + collection.send(:paginate, nil, {}) + expect(client).to have_received(:list_connected_accounts).with(limit: 100) + end + end + + describe '#relations_in' do + it 'returns the unique relation prefixes' do + projection = ['id', 'name', 'connected_account:id', 'connected_account:name', 'foo:bar'] + expect(collection.send(:relations_in, projection)).to contain_exactly('connected_account', 'foo') + end + + it 'returns [] when projection has only columns' do + expect(collection.send(:relations_in, %w[id name])).to eq([]) + end + + it 'returns [] when projection is nil' do + expect(collection.send(:relations_in, nil)).to eq([]) + end + end + + describe '#embed_many_to_one' do + let(:projection) { ['id', 'connected_account:name'] } + let(:rows) { [{ 'id' => 't1' }, { 'id' => 't2' }, { 'id' => 't3' }] } + let(:sources) do + [ + { 'connected_account_id' => 'a' }, + { 'connected_account_id' => 'a' }, # dedup target + { 'connected_account_id' => '' } # blank ignored + ] + end + let(:resolver) { instance_double(Collections::ConnectedAccount) } + let(:embed) do + Collections::BaseCollection::Embed.new( + foreign_key: 'connected_account_id', relation_name: 'connected_account', resolver: resolver + ) + end + + before do + allow(resolver).to receive(:serialize) { |raw| { 'id' => raw['id'], 'name' => raw['name'] } } + end + + it 'fetches the unique FKs in a single batch and assigns the serialized record' do + allow(resolver).to receive(:fetch_by_ids).with(['a']).and_return([{ 'id' => 'a', 'name' => 'Acme' }]) + + collection.send(:embed_many_to_one, rows, sources, projection, embed) + + expect(rows[0]['connected_account']).to eq('id' => 'a', 'name' => 'Acme') + expect(rows[1]['connected_account']).to eq('id' => 'a', 'name' => 'Acme') + expect(rows[2]).not_to have_key('connected_account') + expect(resolver).to have_received(:fetch_by_ids).with(['a']).once + end + + it 'does nothing when the projection does not request the relation' do + allow(resolver).to receive(:fetch_by_ids) + + collection.send(:embed_many_to_one, rows, sources, ['id'], embed) + + expect(rows).to all(satisfy { |r| !r.key?('connected_account') }) + expect(resolver).not_to have_received(:fetch_by_ids) + end + + it 'does nothing when projection is nil' do + allow(resolver).to receive(:fetch_by_ids) + collection.send(:embed_many_to_one, rows, sources, nil, embed) + expect(resolver).not_to have_received(:fetch_by_ids) + end + + it 'does nothing when no source has a usable FK' do + allow(resolver).to receive(:fetch_by_ids) + + empty_sources = [{ 'connected_account_id' => nil }, { 'connected_account_id' => '' }] + collection.send(:embed_many_to_one, [{ 'id' => 'a' }, { 'id' => 'b' }], empty_sources, projection, embed) + + expect(resolver).not_to have_received(:fetch_by_ids) + end + + it 'leaves the relation nil for rows whose record is missing from the batch' do + allow(resolver).to receive(:fetch_by_ids).with(['a']).and_return([]) + + collection.send(:embed_many_to_one, rows, sources, projection, embed) + + expect(rows[0]['connected_account']).to be_nil + expect(rows[1]['connected_account']).to be_nil + end + end + + describe '#translate_filters' do + it 'returns {} for a nil condition tree' do + expect(collection.send(:translate_filters, nil)).to eq({}) + end + + it 'raises on a predicate the collection does not declare in api_filters' do + leaf = Leaf.new('name', 'equal', 'Acme') + expect { collection.send(:translate_filters, leaf) } + .to raise_error(ForestAdminDatasourceMambuPayments::UnsupportedOperatorError) + end + + it 'raises on a combined id predicate (Numeral has no list filter on id)' do + # Pure-id leaves are served by the find-by-id short-circuit; an id ANDed + # with another field would otherwise silently send an ignored param. + leaf = Leaf.new('id', 'equal', 'x') + expect { collection.send(:translate_filters, leaf) } + .to raise_error(ForestAdminDatasourceMambuPayments::UnsupportedOperatorError) + end + end + + describe '#reconcile_filter_operators! (single source of truth for filters)' do + it 'advertises only the operators api_filters can actually serve' do + ops = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + f = collection.schema[:fields] + # id is always filterable… + expect(f['id'].filter_operators).to contain_exactly(ops::EQUAL, ops::IN) + # …but a column absent from api_filters is not filterable at all, so the + # UI never offers a filter that would raise at query time. + expect(f['name'].filter_operators).to eq([]) + end + end + + describe '#aggregate' do + let(:filter) { ForestAdminDatasourceToolkit::Components::Query::Filter.new } + + it 'is not supported (Numeral exposes no count/aggregate endpoint)' do + agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count') + expect { collection.aggregate(nil, filter, agg) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not countable/) + end + + it 'declares the collection non-countable so Forest never requests a count' do + expect(collection.schema[:countable]).to be(false) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/claim_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/claim_spec.rb new file mode 100644 index 000000000..9bd667816 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/claim_spec.rb @@ -0,0 +1,169 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Claim do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:claim) do + { + 'id' => 'clm1', 'object' => 'claim', + 'type' => 'sepa_non_receipt', + 'status' => 'received', 'status_details' => nil, 'reason' => nil, + 'value_date' => '2026-05-20', + 'connected_account_id' => 'acc1', + 'related_payment_type' => 'payment_order', + 'related_payment_id' => 'po1', + 'related_payment' => { 'id' => 'po1', 'amount' => 5000 }, + 'metadata' => {}, + 'bank_data' => { 'message_id' => 'msg-1' }, + 'created_at' => '2026-05-20T08:00:00Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'type', 'status', 'status_details', 'reason', + 'value_date', 'connected_account_id', + 'related_payment_type', 'related_payment_id', 'related_payment', + 'metadata', 'bank_data', 'created_at' + ) + end + + it 'declares a ManyToOne relation to connected_account via connected_account_id' do + rel = collection.schema[:fields]['connected_account'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('connected_account_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'does not expose a typed relation for related_payment_id (polymorphic in Numeral)' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account') + end + + it 'marks every column as read-only (claims are bank-emitted)' do + f = collection.schema[:fields] + %w[id type status status_details reason value_date connected_account_id + related_payment_type related_payment_id related_payment + metadata bank_data created_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'exposes type, status and related_payment_type as Enum columns with Numeral values' do + f = collection.schema[:fields] + expect(f['type'].column_type).to eq('Enum') + expect(f['type'].enum_values).to include('sepa_non_receipt', 'sepa_value_date_correction') + expect(f['status'].column_type).to eq('Enum') + expect(f['status'].enum_values).to include('created', 'processing', 'sent', 'received', + 'accepted', 'rejected') + expect(f['related_payment_type'].column_type).to eq('Enum') + expect(f['related_payment_type'].enum_values).to include('payment_order', 'incoming_payment') + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(claim) + expect(result).to include( + 'id' => 'clm1', 'type' => 'sepa_non_receipt', 'status' => 'received', + 'connected_account_id' => 'acc1', + 'related_payment_type' => 'payment_order', 'related_payment_id' => 'po1' + ) + end + + it 'keeps related_payment and bank_data as embedded Json snapshots' do + result = collection.serialize(claim) + expect(result['related_payment']).to eq('id' => 'po1', 'amount' => 5000) + expect(result['bank_data']).to eq('message_id' => 'msg-1') + end + end + + describe '#list' do + it 'returns rows without resolving the relation when projection has no relation prefix' do + allow(client).to receive(:list_claims).and_return([claim]) + allow(client).to receive(:find_connected_account) + + rows = collection.list(nil, Filter.new, ['id', 'status']) + + expect(rows).to eq([{ 'id' => 'clm1', 'status' => 'received' }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account when the projection asks for it' do + allow(client).to receive(:list_claims).and_return([claim]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + + expect(rows.first['connected_account']).to include('id' => 'acc1', 'name' => 'Acme') + end + + it 'short-circuits to find_claim on id lookup' do + allow(client).to receive(:find_claim).with('clm1').and_return(claim) + allow(client).to receive(:list_claims) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'clm1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_claim).with('clm1') + expect(client).not_to have_received(:list_claims) + end + + it 'drops 404 (nil) records from the result on id lookup' do + allow(client).to receive(:find_claim).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'forwards a translated related_payment_id filter to the API' do + allow(client).to receive(:list_claims).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('related_payment_id', 'equal', 'po1')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_claims) + .with(hash_including('related_payment_id' => 'po1')) + end + + it 'forwards status and type filters to the API' do + allow(client).to receive(:list_claims).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('status', 'equal', 'rejected')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_claims).with(hash_including('status' => 'rejected')) + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_claims) + + filter = Filter.new(condition_tree: Leaf.new('reason', 'equal', 'NOOR')) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'reason'/) + expect(client).not_to have_received(:list_claims) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/connected_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/connected_account_spec.rb new file mode 100644 index 000000000..489747074 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/connected_account_spec.rb @@ -0,0 +1,97 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::ConnectedAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:collection) { described_class.new(datasource) } + + let(:account) do + { + 'id' => 'b6425af8', 'object' => 'connected_account', + 'name' => 'SEPA Indirect', 'type' => 'financial_institution', + 'bank_id' => 'numbank', 'bank_code' => 'BIC', 'bank_name' => 'Bank', + 'currency' => 'EUR', 'services_activated' => %w[sct sdd], + 'file_auto_approval' => true, + 'created_at' => '2026-05-04T08:35:01Z' + } + end + + describe 'schema' do + it 'declares the API-aligned column fields' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'name', 'type', 'bank_id', 'bank_code', 'bank_name', + 'bank_address', 'address', 'services_activated', 'metadata', + 'file_auto_approval', 'disabled_at', 'created_at' + ) + end + + it 'does not expose fictitious fields removed in the alignment pass' do + keys = collection.schema[:fields].keys + %w[holder_name iban bic status service partner_account_id balance_cents].each do |k| + expect(keys).not_to include(k), "schema unexpectedly exposes #{k}" + end + end + + it 'declares OneToMany relations to transactions, payment_orders and balances' do + f = collection.schema[:fields] + %w[transactions payment_orders balances].each do |name| + expect(f[name]).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + end + end + + it 'marks every column as read-only (Numeral has no POST/PATCH/DELETE on /connected_accounts)' do + f = collection.schema[:fields].reject do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + end + f.each do |name, schema| + expect(schema.is_read_only).to be(true), "#{name} should be read-only" + end + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(account) + expect(result).to include('id' => 'b6425af8', 'name' => 'SEPA Indirect', + 'services_activated' => %w[sct sdd], + 'file_auto_approval' => true) + end + end + + describe '#list' do + it 'short-circuits to find_connected_account on id lookup' do + allow(client).to receive(:find_connected_account).with('b6425af8').and_return(account) + allow(client).to receive(:list_connected_accounts) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'b6425af8')) + rows = collection.list(nil, filter, nil) + + expect(rows.first['id']).to eq('b6425af8') + expect(client).not_to have_received(:list_connected_accounts) + end + + it 'falls back to a paginated list when there is no id filter' do + allow(client).to receive(:list_connected_accounts).and_return([account]) + + rows = collection.list(nil, Filter.new, ['id', 'name']) + + expect(rows).to eq([{ 'id' => 'b6425af8', 'name' => 'SEPA Indirect' }]) + expect(client).to have_received(:list_connected_accounts).with(limit: Client::MAX_PER_PAGE) + end + + it 'drops 404 (nil) records from the result' do + allow(client).to receive(:find_connected_account).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate_spec.rb new file mode 100644 index 000000000..00aa48d34 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate_spec.rb @@ -0,0 +1,166 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::DirectDebitMandate do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:ea_collection) { Collections::ExternalAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:mandate) do + { + 'id' => 'dm1', 'object' => 'direct_debit_mandate', + 'connected_account_id' => 'acc1', + 'external_account_id' => 'ea1', + 'type' => 'sepa_core', 'scheme' => 'sepa', + 'status' => 'active', 'sequence_type' => 'recurrent', + 'reference' => 'MNDREF-001', 'unique_mandate_reference' => 'UMR-1', + 'creditor_identifier' => 'FR00ZZZ123456', + 'signature_date' => '2026-01-15', 'signature_location' => 'Paris', + 'creditor' => { 'name' => 'Acme SAS' }, + 'debtor' => { 'name' => 'Jane Doe' }, + 'debtor_account' => { 'account_number' => 'FR..' }, + 'amendment_information' => nil, + 'custom_fields' => {}, 'metadata' => {}, + 'created_at' => '2026-01-15T08:50:28Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + allow(datasource).to receive(:get_collection).with('MambuExternalAccount').and_return(ea_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'external_account_id', 'type', 'scheme', + 'status', 'sequence_type', 'reference', 'unique_mandate_reference', + 'creditor_identifier', 'signature_date', 'signature_location', + 'creditor', 'debtor', 'debtor_account', 'amendment_information', + 'custom_fields', 'metadata', 'created_at' + ) + end + + it 'declares ManyToOne to connected_account and external_account' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account', 'external_account') + end + + it 'exposes sequence_type and scheme as Enum columns with constrained values' do + f = collection.schema[:fields] + expect(f['sequence_type'].column_type).to eq('Enum') + expect(f['sequence_type'].enum_values).to contain_exactly('one_off', 'recurrent', 'first', 'final') + expect(f['scheme'].column_type).to eq('Enum') + expect(f['scheme'].enum_values).to contain_exactly('sepa', 'bacs', 'ach') + end + + it 'keeps creditor / debtor / debtor_account as Json (embedded snapshots)' do + f = collection.schema[:fields] + %w[creditor debtor debtor_account].each do |k| + expect(f[k].column_type).to eq('Json') + end + end + + it 'marks system-managed fields as read-only' do + f = collection.schema[:fields] + %w[id status created_at].each { |k| expect(f[k].is_read_only).to be(true) } + end + end + + describe '#list' do + it 'returns rows without resolving relations when projection has no relation prefix' do + allow(client).to receive(:list_direct_debit_mandates).and_return([mandate]) + allow(client).to receive(:find_connected_account) + allow(client).to receive(:find_external_account) + + rows = collection.list(nil, Filter.new, ['id', 'unique_mandate_reference']) + + expect(rows).to eq([{ 'id' => 'dm1', 'unique_mandate_reference' => 'UMR-1' }]) + expect(client).not_to have_received(:find_connected_account) + expect(client).not_to have_received(:find_external_account) + end + + it 'embeds connected_account when requested by the projection' do + allow(client).to receive(:list_direct_debit_mandates).and_return([mandate]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'embeds external_account when requested by the projection' do + allow(client).to receive(:list_direct_debit_mandates).and_return([mandate]) + allow(client).to receive(:find_external_account).with('ea1').and_return('id' => 'ea1') + + rows = collection.list(nil, Filter.new, ['id', 'external_account:id']) + expect(rows.first['external_account']).to include('id' => 'ea1') + end + + it 'short-circuits to find_direct_debit_mandate on id lookup' do + allow(client).to receive(:find_direct_debit_mandate).with('dm1').and_return(mandate) + allow(client).to receive(:list_direct_debit_mandates) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'dm1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_direct_debit_mandate).with('dm1') + expect(client).not_to have_received(:list_direct_debit_mandates) + end + end + + describe '#create' do + it 'strips system-managed fields before POSTing' do + allow(client).to receive(:create_direct_debit_mandate) do |payload| + expect(payload).to include('unique_mandate_reference' => 'UMR-1') + expect(payload.keys).not_to include('id', 'object', 'status', 'created_at') + { 'id' => 'dm1', 'unique_mandate_reference' => 'UMR-1' } + end + + collection.create(nil, + 'id' => 'ignored', 'object' => 'direct_debit_mandate', + 'status' => 'active', 'created_at' => 't', + 'unique_mandate_reference' => 'UMR-1') + + expect(client).to have_received(:create_direct_debit_mandate) + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter' do + allow(client).to receive(:find_direct_debit_mandate).with('a').and_return('id' => 'a') + allow(client).to receive(:find_direct_debit_mandate).with('b').and_return('id' => 'b') + allow(client).to receive(:update_direct_debit_mandate) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'reference' => 'NEWREF') + + expect(client).to have_received(:update_direct_debit_mandate) + .with('a', hash_including('reference' => 'NEWREF')) + expect(client).to have_received(:update_direct_debit_mandate) + .with('b', hash_including('reference' => 'NEWREF')) + end + end + + describe '#delete' do + it 'DELETEs each id resolved by the filter' do + allow(client).to receive(:find_direct_debit_mandate).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_direct_debit_mandate) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_direct_debit_mandate).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/event_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/event_spec.rb new file mode 100644 index 000000000..931b70fde --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/event_spec.rb @@ -0,0 +1,184 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Event do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:po_collection) { Collections::PaymentOrder.new(datasource) } + let(:tx_collection) { Collections::Transaction.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:payment_order_event) do + { + 'id' => 'ev1', 'object' => 'event', + 'topic' => 'payment_order', 'type' => 'executed', + 'related_object_id' => 'po1', 'related_object_type' => 'payment_order', + 'status' => 'delivered', 'status_details' => '', + 'webhook_id' => 'wh1', + 'data' => { 'id' => 'po1', 'connected_account_id' => 'acc1' }, + 'created_at' => '2026-03-12T03:30:06Z' + } + end + + let(:transaction_event) do + { + 'id' => 'ev2', 'object' => 'event', + 'topic' => 'transaction', 'type' => 'created', + 'related_object_id' => 'tx1', 'related_object_type' => 'transaction', + 'status' => 'created', 'status_details' => nil, + 'webhook_id' => nil, + 'data' => { 'id' => 'tx1' }, + 'created_at' => '2026-03-12T03:31:00Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuPaymentOrder').and_return(po_collection) + allow(datasource).to receive(:get_collection).with('MambuTransaction').and_return(tx_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'topic', 'type', + 'related_object_id', 'related_object_type', + 'status', 'status_details', 'webhook_id', 'data', 'created_at' + ) + end + + it 'exposes status as an Enum with the Numeral lifecycle values' do + f = collection.schema[:fields] + expect(f['status'].column_type).to eq('Enum') + expect(f['status'].enum_values) + .to contain_exactly('created', 'delivered', 'pending_retry', 'failed', 'archived') + end + + it 'declares a PolymorphicManyToOne relation to every Mambu collection' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema) + end + expect(rels.keys).to contain_exactly('related_object') + rel = rels['related_object'] + expect(rel.foreign_key).to eq('related_object_id') + expect(rel.foreign_key_type_field).to eq('related_object_type') + expect(rel.foreign_collections).to contain_exactly( + 'MambuPaymentOrder', 'MambuTransaction', 'MambuIncomingPayment', + 'MambuExpectedPayment', 'MambuDirectDebitMandate', 'MambuBalance', + 'MambuConnectedAccount', 'MambuAccountHolder', + 'MambuInternalAccount', 'MambuExternalAccount' + ) + end + + it 'marks every column as read-only (events are immutable)' do + f = collection.schema[:fields] + %w[topic type status webhook_id data related_object_id related_object_type].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#serialize' do + it 'translates related_object_type from the Numeral string to the Forest collection name' do + row = collection.serialize(payment_order_event) + expect(row['related_object_type']).to eq('MambuPaymentOrder') + expect(row['topic']).to eq('payment_order') + end + + it 'keeps the raw type string when it is not in the known mapping' do + row = collection.serialize(payment_order_event.merge('related_object_type' => 'webhook')) + expect(row['related_object_type']).to eq('webhook') + end + end + + describe '#list with server-side filter' do + it 'forwards related_object_id equality to the Numeral list endpoint' do + allow(client).to receive(:list_events).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('related_object_id', 'equal', 'po1')) + collection.list(nil, filter, %w[id]) + + expect(client).to have_received(:list_events) + .with(hash_including('related_object_id' => 'po1')) + end + + it 'forwards related_object_id IN to the Numeral list endpoint' do + allow(client).to receive(:list_events).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('related_object_id', 'in', %w[po1 po2])) + collection.list(nil, filter, %w[id]) + + expect(client).to have_received(:list_events) + .with(hash_including('related_object_id' => %w[po1 po2])) + end + end + + describe '#list' do + it 'returns rows without resolving related_object when projection has no relation prefix' do + allow(client).to receive(:list_events).and_return([payment_order_event]) + allow(client).to receive(:find_payment_order) + + rows = collection.list(nil, Filter.new, ['id', 'topic']) + + expect(rows).to eq([{ 'id' => 'ev1', 'topic' => 'payment_order' }]) + expect(client).not_to have_received(:find_payment_order) + end + + it 'embeds the related payment_order when requested by the projection' do + allow(client).to receive(:list_events).and_return([payment_order_event]) + allow(client).to receive(:find_payment_order).with('po1') + .and_return('id' => 'po1', 'amount' => 75_000) + + rows = collection.list(nil, Filter.new, ['id', 'related_object:id']) + + expect(rows.first['related_object']).to include('id' => 'po1', 'amount' => 75_000) + end + + it 'batches per related collection and avoids duplicate find_* calls' do + allow(client).to receive(:list_events) + .and_return([payment_order_event, payment_order_event, transaction_event]) + allow(client).to receive(:find_payment_order).with('po1').and_return('id' => 'po1') + allow(client).to receive(:find_transaction).with('tx1').and_return('id' => 'tx1') + + collection.list(nil, Filter.new, ['id', 'related_object:id']) + + expect(client).to have_received(:find_payment_order).with('po1').once + expect(client).to have_received(:find_transaction).with('tx1').once + end + + it 'leaves related_object unset when the related_object_type is unknown' do + unknown = payment_order_event.merge('related_object_type' => 'webhook') + allow(client).to receive(:list_events).and_return([unknown]) + + rows = collection.list(nil, Filter.new, ['id', 'related_object:id']) + + expect(rows.first).not_to have_key('related_object') + end + + it 'short-circuits to find_event on id lookup' do + allow(client).to receive(:find_event).with('ev1').and_return(payment_order_event) + allow(client).to receive(:list_events) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'ev1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_event).with('ev1') + expect(client).not_to have_received(:list_events) + end + + it 'projects to the requested column subset' do + allow(client).to receive(:list_events).and_return([payment_order_event]) + rows = collection.list(nil, Filter.new, %w[id status topic]) + expect(rows.first).to eq('id' => 'ev1', 'status' => 'delivered', 'topic' => 'payment_order') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/expected_payment_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/expected_payment_spec.rb new file mode 100644 index 000000000..deca77ea7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/expected_payment_spec.rb @@ -0,0 +1,206 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::ExpectedPayment do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:ia_collection) { Collections::InternalAccount.new(datasource) } + let(:ea_collection) { Collections::ExternalAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:expected_payment) do + { + 'id' => '019e17e6-bac7-7607-9d91-12147d8db4c8', + 'idempotency_key' => '', + 'object' => 'expected_payment', + 'direction' => 'debit', + 'amount_from' => 5000, 'amount_to' => 6000, + 'currency' => 'EUR', + 'start_date' => '2026-05-11', 'end_date' => '2026-05-11', + 'connected_account_id' => '456d2975-d58b-4a90-89b8-efcc3239c866', + 'external_account' => { 'account_number' => 'AG454545', 'holder_name' => 'test external account' }, + 'external_account_id' => '', + 'internal_account' => { 'account_number' => '43244675643525' }, + 'internal_account_id' => '', + 'reconciliation_status' => 'unreconciled', + 'reconciled_amount' => 0, + 'metadata' => {}, 'custom_fields' => {}, + 'descriptions' => ['test expected payment'], + 'created_at' => '2026-05-11T16:37:37.612847Z', + 'updated_at' => '2026-05-11T16:37:37.612855Z', + 'canceled_at' => nil + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + allow(datasource).to receive(:get_collection).with('MambuInternalAccount').and_return(ia_collection) + allow(datasource).to receive(:get_collection).with('MambuExternalAccount').and_return(ea_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'idempotency_key', + 'connected_account_id', 'internal_account_id', 'external_account_id', + 'direction', 'amount_from', 'amount_to', 'currency', + 'start_date', 'end_date', 'descriptions', + 'reconciliation_status', 'reconciled_amount', + 'custom_fields', 'metadata', + 'created_at', 'updated_at', 'canceled_at' + ) + end + + it 'does not expose fields that are absent from the Numeral payload' do + keys = collection.schema[:fields].keys + # The account data is exposed through the ManyToOne relations, not as + # embedded snapshot columns (single source of truth, like Transaction). + %w[amount amount_min amount_max status type reference end_to_end_id + expected_at earliest_expected_at latest_expected_at + counterparty matched_amount matched_payments + internal_account_snapshot external_account_snapshot].each do |k| + expect(keys).not_to include(k), "schema unexpectedly exposes #{k}" + end + end + + it 'declares ManyToOne to connected_account, internal_account and external_account' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account', 'internal_account', 'external_account') + end + + it 'exposes direction as an Enum constrained to debit/credit' do + f = collection.schema[:fields] + expect(f['direction'].column_type).to eq('Enum') + expect(f['direction'].enum_values).to contain_exactly('debit', 'credit') + end + + it 'keeps descriptions as Json' do + f = collection.schema[:fields] + expect(f['descriptions'].column_type).to eq('Json') + end + + it 'marks reconciliation outcome and timestamps as read-only' do + f = collection.schema[:fields] + %w[id object reconciliation_status reconciled_amount + created_at updated_at canceled_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + end + + describe '#list' do + it 'serializes amount_from/to, start/end_date and descriptions' do + allow(client).to receive(:list_expected_payments).and_return([expected_payment]) + + rows = collection.list(nil, Filter.new, + %w[id amount_from amount_to start_date end_date descriptions]) + + expect(rows.first).to include( + 'amount_from' => 5000, 'amount_to' => 6000, + 'start_date' => '2026-05-11', 'end_date' => '2026-05-11', + 'descriptions' => ['test expected payment'] + ) + end + + it 'returns rows without resolving relations when projection has no relation prefix' do + allow(client).to receive(:list_expected_payments).and_return([expected_payment]) + allow(client).to receive(:find_connected_account) + allow(client).to receive(:find_internal_account) + allow(client).to receive(:find_external_account) + + collection.list(nil, Filter.new, %w[id amount_from]) + + expect(client).not_to have_received(:find_connected_account) + expect(client).not_to have_received(:find_internal_account) + expect(client).not_to have_received(:find_external_account) + end + + it 'embeds connected_account when requested' do + allow(client).to receive(:list_expected_payments).and_return([expected_payment]) + allow(client).to receive(:find_connected_account) + .with('456d2975-d58b-4a90-89b8-efcc3239c866') + .and_return(account.merge('id' => '456d2975-d58b-4a90-89b8-efcc3239c866')) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'skips internal/external account fetches when their FK is the empty string' do + allow(client).to receive(:list_expected_payments).and_return([expected_payment]) + allow(client).to receive(:find_internal_account) + allow(client).to receive(:find_external_account) + + collection.list(nil, Filter.new, ['id', 'internal_account:id', 'external_account:id']) + + expect(client).not_to have_received(:find_internal_account) + expect(client).not_to have_received(:find_external_account) + end + + it 'short-circuits to find_expected_payment on id lookup' do + allow(client).to receive(:find_expected_payment) + .with('019e17e6-bac7-7607-9d91-12147d8db4c8').and_return(expected_payment) + allow(client).to receive(:list_expected_payments) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', '019e17e6-bac7-7607-9d91-12147d8db4c8')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_expected_payment) + expect(client).not_to have_received(:list_expected_payments) + end + end + + describe '#create' do + it 'strips system-managed fields before POSTing' do + allow(client).to receive(:create_expected_payment) do |payload| + expect(payload).to include('amount_from' => 5000, 'amount_to' => 6000, 'direction' => 'debit') + expect(payload.keys).not_to include('id', 'object', 'reconciliation_status', 'reconciled_amount', + 'created_at', 'updated_at', 'canceled_at') + { 'id' => 'ep1', 'amount_from' => 5000 } + end + + collection.create(nil, + 'id' => 'ignored', 'object' => 'expected_payment', + 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, + 'created_at' => 't', 'updated_at' => 't', 'canceled_at' => nil, + 'amount_from' => 5000, 'amount_to' => 6000, 'direction' => 'debit') + + expect(client).to have_received(:create_expected_payment) + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter' do + allow(client).to receive(:find_expected_payment).with('a').and_return('id' => 'a') + allow(client).to receive(:find_expected_payment).with('b').and_return('id' => 'b') + allow(client).to receive(:update_expected_payment) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'amount_to' => 7000) + + expect(client).to have_received(:update_expected_payment).with('a', hash_including('amount_to' => 7000)) + expect(client).to have_received(:update_expected_payment).with('b', hash_including('amount_to' => 7000)) + end + end + + describe '#delete' do + it 'DELETEs each id resolved by the filter' do + allow(client).to receive(:find_expected_payment).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_expected_payment) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_expected_payment).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/external_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/external_account_spec.rb new file mode 100644 index 000000000..b23ccb902 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/external_account_spec.rb @@ -0,0 +1,116 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::ExternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ah_collection) { Collections::AccountHolder.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:holder) { { 'id' => 'ah1', 'name' => 'Christophe' } } + let(:external_account) do + { + 'id' => '019e16fb', 'object' => 'external_account', + 'type' => 'individual', 'status' => 'approved', + 'name' => 'ext acc', 'holder_name' => 'Christophe', + 'holder_address' => { 'country' => '' }, + 'account_number' => 'EZR341234213', 'bank_code' => 'erez', + 'account_holder_id' => 'ah1', + 'created_at' => '2026-05-11T12:20:17Z', 'disabled_at' => nil + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuAccountHolder').and_return(ah_collection) + end + + describe 'schema' do + it 'declares the main API columns and the account_holder relation' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'type', 'status', 'name', 'holder_name', 'holder_address', + 'account_number', 'bank_code', 'account_holder_id', + 'company_registration_number', 'metadata', 'custom_fields', + 'account_verification', 'created_at', 'disabled_at' + ) + expect(collection.schema[:fields]['account_holder']) + .to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + + it 'marks status, status_details and account_verification read-only (Numeral-managed)' do + f = collection.schema[:fields] + %w[status status_details account_verification].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + end + + describe '#list' do + it 'embeds account_holder when requested by the projection' do + allow(client).to receive(:list_external_accounts).and_return([external_account]) + allow(client).to receive(:find_account_holder).with('ah1').and_return(holder) + + rows = collection.list(nil, Filter.new, ['id', 'account_holder:name']) + expect(rows.first['account_holder']).to include('name' => 'Christophe') + end + + it 'short-circuits to find_external_account on id lookup' do + allow(client).to receive(:find_external_account).with('019e16fb').and_return(external_account) + allow(client).to receive(:list_external_accounts) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', '019e16fb')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_external_account).with('019e16fb') + expect(client).not_to have_received(:list_external_accounts) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_external_account) do |payload| + expect(payload).to include('name' => 'New') + expect(payload.keys).not_to include('id', 'object', 'status', 'status_details', + 'created_at', 'disabled_at', 'account_verification') + { 'id' => 'new', 'name' => 'New' } + end + + collection.create(nil, + 'id' => 'ignored', 'object' => 'external_account', + 'status' => 'approved', 'status_details' => '', + 'created_at' => 't', 'disabled_at' => nil, + 'account_verification' => {}, 'name' => 'New') + + expect(client).to have_received(:create_external_account) + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter' do + allow(client).to receive(:find_external_account).with('a').and_return('id' => 'a') + allow(client).to receive(:update_external_account) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'equal', 'a')), + 'name' => 'Renamed') + + expect(client).to have_received(:update_external_account).with('a', hash_including('name' => 'Renamed')) + end + end + + describe '#delete' do + it 'DELETEs each id resolved by the filter' do + allow(client).to receive(:find_external_account).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_external_account) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_external_account).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/file_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/file_spec.rb new file mode 100644 index 000000000..bfe28fa72 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/file_spec.rb @@ -0,0 +1,116 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::File do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:file_record) do + { + 'id' => 'f1', 'object' => 'file', + 'connected_account_id' => 'acc1', + 'connected_account_ids' => %w[acc1 acc2], + 'direction' => 'outgoing', 'category' => 'payment_file', + 'format' => 'pain.001', 'filename' => 'SFPP30X40.f1.1659349967', + 'size' => 2234, 'summary' => {}, + 'status' => 'sent', 'status_details' => 'generated on 2024-08-01', + 'bank_data' => { 'message_ids' => ['211012231391882'] }, + 'created_at' => '2024-08-01T06:00:05Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'connected_account_id', 'connected_account_ids', + 'direction', 'category', 'format', 'filename', 'size', 'summary', + 'status', 'status_details', 'bank_data', 'created_at' + ) + end + + it 'exposes direction and status as Enum columns with the Numeral values' do + f = collection.schema[:fields] + expect(f['direction'].column_type).to eq('Enum') + expect(f['direction'].enum_values).to contain_exactly('incoming', 'outgoing') + expect(f['status'].column_type).to eq('Enum') + expect(f['status'].enum_values).to contain_exactly( + 'created', 'approved', 'canceled', 'sent', 'rejected', 'processed', 'received' + ) + end + + it 'declares a ManyToOne to connected_account' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account') + end + + it 'keeps connected_account_ids as a Json array (files can span several accounts)' do + f = collection.schema[:fields] + expect(f['connected_account_ids'].column_type).to eq('Json') + end + + it 'marks every column as read-only (files are bank-emitted)' do + f = collection.schema[:fields] + %w[direction category format filename status size summary bank_data].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#list' do + it 'returns rows without resolving connected_account when projection has no relation prefix' do + allow(client).to receive(:list_files).and_return([file_record]) + allow(client).to receive(:find_connected_account) + + rows = collection.list(nil, Filter.new, %w[id filename]) + + expect(rows).to eq([{ 'id' => 'f1', 'filename' => 'SFPP30X40.f1.1659349967' }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account when requested by the projection' do + allow(client).to receive(:list_files).and_return([file_record]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'short-circuits to find_file on id lookup' do + allow(client).to receive(:find_file).with('f1').and_return(file_record) + allow(client).to receive(:list_files) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'f1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_file).with('f1') + expect(client).not_to have_received(:list_files) + end + + it 'projects to the requested column subset' do + allow(client).to receive(:list_files).and_return([file_record]) + rows = collection.list(nil, Filter.new, %w[id status direction format]) + expect(rows.first).to eq( + 'id' => 'f1', 'status' => 'sent', 'direction' => 'outgoing', 'format' => 'pain.001' + ) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/incoming_payment_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/incoming_payment_spec.rb new file mode 100644 index 000000000..6d91c87e7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/incoming_payment_spec.rb @@ -0,0 +1,132 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::IncomingPayment do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:ia_collection) { Collections::InternalAccount.new(datasource) } + let(:ea_collection) { Collections::ExternalAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:incoming_payment) do + { + 'id' => 'ip1', 'object' => 'incoming_payment', + 'connected_account_id' => 'acc1', + 'type' => 'sepa_credit_transfer', 'status' => 'received', + 'amount' => 12_500, 'currency' => 'EUR', + 'end_to_end_id' => 'e2e', 'uetr' => 'u-1', + 'reference' => 'REF', 'structured_reference' => nil, + 'value_date' => '2026-05-11', 'booking_date' => '2026-05-11', + 'originating_account' => { 'account_number' => 'DE..' }, + 'receiving_account' => { 'account_number' => 'BE..' }, + 'internal_account_id' => 'ia1', 'external_account_id' => 'ea1', + 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, + 'return_information' => nil, 'custom_fields' => {}, 'metadata' => {}, + 'created_at' => '2026-05-11T07:10:57Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + allow(datasource).to receive(:get_collection).with('MambuInternalAccount').and_return(ia_collection) + allow(datasource).to receive(:get_collection).with('MambuExternalAccount').and_return(ea_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'type', 'status', 'amount', 'currency', + 'end_to_end_id', 'uetr', 'reference', 'structured_reference', + 'value_date', 'booking_date', + 'originating_account', 'receiving_account', + 'internal_account_id', 'external_account_id', + 'reconciliation_status', 'reconciled_amount', 'return_information', + 'custom_fields', 'metadata', 'created_at' + ) + end + + it 'declares ManyToOne to connected_account, internal_account and external_account' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account', 'internal_account', 'external_account') + end + + it 'marks every column as read-only (incoming payments are immutable)' do + f = collection.schema[:fields] + %w[type status amount currency reference value_date booking_date custom_fields metadata].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + + it 'keeps originating_account / receiving_account as Json (embedded snapshots)' do + f = collection.schema[:fields] + expect(f['originating_account'].column_type).to eq('Json') + expect(f['receiving_account'].column_type).to eq('Json') + end + end + + describe '#list' do + it 'returns rows without resolving relations when projection has no relation prefix' do + allow(client).to receive(:list_incoming_payments).and_return([incoming_payment]) + allow(client).to receive(:find_connected_account) + allow(client).to receive(:find_internal_account) + allow(client).to receive(:find_external_account) + + rows = collection.list(nil, Filter.new, ['id', 'amount']) + + expect(rows).to eq([{ 'id' => 'ip1', 'amount' => 12_500 }]) + expect(client).not_to have_received(:find_connected_account) + expect(client).not_to have_received(:find_internal_account) + expect(client).not_to have_received(:find_external_account) + end + + it 'embeds connected_account when requested by the projection' do + allow(client).to receive(:list_incoming_payments).and_return([incoming_payment]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'embeds internal_account and external_account when requested' do + allow(client).to receive(:list_incoming_payments).and_return([incoming_payment]) + allow(client).to receive(:find_internal_account).with('ia1').and_return('id' => 'ia1') + allow(client).to receive(:find_external_account).with('ea1').and_return('id' => 'ea1') + + rows = collection.list(nil, Filter.new, ['id', 'internal_account:id', 'external_account:id']) + + expect(rows.first['internal_account']).to include('id' => 'ia1') + expect(rows.first['external_account']).to include('id' => 'ea1') + end + + it 'short-circuits to find_incoming_payment on id lookup' do + allow(client).to receive(:find_incoming_payment).with('ip1').and_return(incoming_payment) + allow(client).to receive(:list_incoming_payments) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'ip1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_incoming_payment).with('ip1') + expect(client).not_to have_received(:list_incoming_payments) + end + + it 'projects to the requested column subset' do + allow(client).to receive(:list_incoming_payments).and_return([incoming_payment]) + rows = collection.list(nil, Filter.new, %w[id status amount]) + expect(rows.first).to eq('id' => 'ip1', 'status' => 'received', 'amount' => 12_500) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/internal_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/internal_account_spec.rb new file mode 100644 index 000000000..fdba72e34 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/internal_account_spec.rb @@ -0,0 +1,116 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::InternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ah_collection) { Collections::AccountHolder.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:holder) { { 'id' => 'ah1', 'name' => 'Christophe' } } + let(:internal_account) do + { + 'id' => '019e1671', 'object' => 'internal_account', + 'status' => 'active', 'type' => 'own', + 'name' => 'Internal Account Test', 'holder_name' => 'Christophe', + 'connected_account_ids' => ['b6425af8'], + 'account_number' => 'AZ342544', 'bank_code' => 'TRE3635467', + 'account_holder_id' => 'ah1', + 'currencies' => ['EUR'], 'synchronized_with_bank' => false, + 'created_at' => '2026-05-11T09:50:06Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuAccountHolder').and_return(ah_collection) + end + + describe 'schema' do + it 'declares the main API columns including connected_account_ids as Json' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'status', 'type', 'name', 'holder_name', + 'connected_account_ids', 'account_number', 'bank_code', + 'account_holder_id', 'currencies', 'synchronized_with_bank', + 'cbs_account_id', 'distinguished_name', 'metadata', 'bank_data', 'created_at' + ) + expect(collection.schema[:fields]['connected_account_ids'].column_type).to eq('Json') + end + + it 'declares synchronized_with_bank as a Boolean column' do + expect(collection.schema[:fields]['synchronized_with_bank'].column_type).to eq('Boolean') + end + + it 'declares the account_holder ManyToOne relation' do + expect(collection.schema[:fields]['account_holder']) + .to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + end + + describe '#list' do + it 'embeds account_holder when requested by the projection' do + allow(client).to receive(:list_internal_accounts).and_return([internal_account]) + allow(client).to receive(:find_account_holder).with('ah1').and_return(holder) + + rows = collection.list(nil, Filter.new, ['id', 'account_holder:name']) + expect(rows.first['account_holder']).to include('name' => 'Christophe') + end + + it 'short-circuits to find_internal_account on id lookup' do + allow(client).to receive(:find_internal_account).with('019e1671').and_return(internal_account) + allow(client).to receive(:list_internal_accounts) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', '019e1671')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_internal_account).with('019e1671') + expect(client).not_to have_received(:list_internal_accounts) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_internal_account) do |payload| + expect(payload).to include('name' => 'New') + expect(payload.keys).not_to include('id', 'object', 'status', 'status_details', 'created_at', 'bank_data') + { 'id' => 'new', 'name' => 'New' } + end + + collection.create(nil, + 'id' => 'ignored', 'object' => 'internal_account', + 'status' => 'active', 'status_details' => '', + 'created_at' => 't', 'bank_data' => {}, 'name' => 'New') + + expect(client).to have_received(:create_internal_account) + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter' do + allow(client).to receive(:find_internal_account).with('a').and_return('id' => 'a') + allow(client).to receive(:update_internal_account) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'equal', 'a')), + 'name' => 'Renamed') + + expect(client).to have_received(:update_internal_account).with('a', hash_including('name' => 'Renamed')) + end + end + + describe '#delete' do + it 'DELETEs each id resolved by the filter' do + allow(client).to receive(:find_internal_account).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_internal_account) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_internal_account).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payee_verification_request_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payee_verification_request_spec.rb new file mode 100644 index 000000000..2b27f5602 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payee_verification_request_spec.rb @@ -0,0 +1,167 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::PayeeVerificationRequest do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:collection) { described_class.new(datasource) } + + let(:payee_verification_request) do + { + 'id' => 'pvr1', 'object' => 'payee_verification_request', + 'status' => 'completed', 'failure_code' => nil, 'status_details' => nil, + 'direction' => 'outgoing', 'scheme' => 'vop', + 'request' => { + 'payee_identification_type' => 'iban', + 'payee_identification' => 'BE68539007547034', + 'party_account_number' => 'BE68539007547034', + 'requesting_agent_bank_code' => 'DEUTDEFF', + 'responding_agent_bank_code' => 'GKCCBEBB' + }, + 'matching_result' => 'match', + 'payee_suggested_name' => nil, + 'matching_details' => { + 'cleaned_identification' => 'BE68539007547034', + 'matching_score' => 100 + }, + 'scheme_data' => { + 'scheme_request_id' => 'scheme-1', + 'request_timestamp' => '2026-05-21T08:00:00Z', + 'response_timestamp' => '2026-05-21T08:00:30Z' + }, + 'metadata' => { 'src' => 'sandbox' }, + 'response_received_at' => '2026-05-21T08:00:30Z', + 'created_at' => '2026-05-21T08:00:00Z' + } + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'status', 'failure_code', 'status_details', + 'direction', 'scheme', 'request', + 'matching_result', 'payee_suggested_name', + 'matching_details', 'scheme_data', 'metadata', + 'response_received_at', 'created_at' + ) + end + + it 'exposes status, failure_code, direction, scheme and matching_result as Enum columns' do + f = collection.schema[:fields] + expect(f['status'].column_type).to eq('Enum') + expect(f['status'].enum_values).to contain_exactly('completed', 'failed') + expect(f['failure_code'].enum_values) + .to contain_exactly('business_error', 'technical_error', 'psp_technical_error') + expect(f['direction'].enum_values).to contain_exactly('outgoing', 'incoming') + expect(f['scheme'].enum_values).to contain_exactly('vop') + expect(f['matching_result'].enum_values) + .to contain_exactly('match', 'close_match', 'no_match', 'impossible_match') + end + + it 'keeps request, matching_details, scheme_data and metadata as Json snapshots' do + f = collection.schema[:fields] + %w[request matching_details scheme_data metadata].each do |k| + expect(f[k].column_type).to eq('Json'), "#{k} should be Json" + end + end + + it 'declares no ManyToOne relations (no top-level FK in Numeral payload)' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels).to be_empty + end + + it 'marks every column as read-only (payee verification requests are bank/PSP-emitted)' do + f = collection.schema[:fields] + %w[id status failure_code direction scheme request matching_result + matching_details scheme_data metadata response_received_at created_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(payee_verification_request) + expect(result).to include( + 'id' => 'pvr1', 'status' => 'completed', 'direction' => 'outgoing', + 'scheme' => 'vop', 'matching_result' => 'match' + ) + end + + it 'preserves nested objects as Json snapshots' do + result = collection.serialize(payee_verification_request) + expect(result['request']).to include('payee_identification' => 'BE68539007547034') + expect(result['matching_details']).to include('matching_score' => 100) + expect(result['scheme_data']).to include('scheme_request_id' => 'scheme-1') + end + end + + describe '#list' do + it 'returns rows projected to the requested columns' do + allow(client).to receive(:list_payee_verification_requests).and_return([payee_verification_request]) + + rows = collection.list(nil, Filter.new, %w[id status matching_result]) + + expect(rows).to eq([{ 'id' => 'pvr1', 'status' => 'completed', 'matching_result' => 'match' }]) + end + + it 'short-circuits to find_payee_verification_request on id lookup' do + allow(client).to receive(:find_payee_verification_request) + .with('pvr1').and_return(payee_verification_request) + allow(client).to receive(:list_payee_verification_requests) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'pvr1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_payee_verification_request).with('pvr1') + expect(client).not_to have_received(:list_payee_verification_requests) + end + + it 'drops 404 (nil) records from the result on id lookup' do + allow(client).to receive(:find_payee_verification_request).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'forwards translated status, direction, scheme and matching_result filters to the API' do + allow(client).to receive(:list_payee_verification_requests).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('status', 'equal', 'failed')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payee_verification_requests) + .with(hash_including('status' => 'failed')) + + filter = Filter.new(condition_tree: Leaf.new('direction', 'equal', 'incoming')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payee_verification_requests) + .with(hash_including('direction' => 'incoming')) + + filter = Filter.new(condition_tree: Leaf.new('matching_result', 'equal', 'no_match')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payee_verification_requests) + .with(hash_including('matching_result' => 'no_match')) + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_payee_verification_requests) + + filter = Filter.new(condition_tree: Leaf.new('payee_suggested_name', 'equal', 'Alice')) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'payee_suggested_name'/) + expect(client).not_to have_received(:list_payee_verification_requests) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_capture_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_capture_spec.rb new file mode 100644 index 000000000..46a6e7499 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_capture_spec.rb @@ -0,0 +1,187 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::PaymentCapture do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:payment_capture) do + { + 'id' => 'pc1', 'object' => 'payment_capture', + 'idempotency_key' => 'idem-1', + 'connected_account_id' => 'acc1', + 'type' => 'charge', 'source' => 'reporting_file', + 'amount' => 2000, 'original_payment_amount' => nil, + 'currency' => 'EUR', + 'date' => '2026-05-20', 'value_date' => '2026-05-21', + 'remittance_date' => '2026-05-22', 'remittance_reference' => 'REM-1', + 'transaction_reference' => 'TXN-1', 'authorization_id' => 'AUTH-1', + 'payment_reference' => 'PR-1', 'network' => 'visa', + 'merchant_id' => 'M-1', + 'fee_amount' => 10, 'fee_amount_currency' => 'EUR', + 'net_amount' => 1990, 'net_amount_currency' => 'EUR', + 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, + 'cbs_data' => { 'sys' => 'core' }, 'lending' => { 'loan_ids' => [] }, + 'metadata' => { 'src' => 'sandbox' }, + 'canceled_at' => nil, 'updated_at' => '2026-05-21T08:00:00Z', + 'created_at' => '2026-05-20T10:00:00Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'idempotency_key', 'connected_account_id', + 'type', 'source', 'amount', 'original_payment_amount', 'currency', + 'date', 'value_date', 'remittance_date', 'remittance_reference', + 'transaction_reference', 'authorization_id', 'payment_reference', + 'network', 'merchant_id', + 'fee_amount', 'fee_amount_currency', 'net_amount', 'net_amount_currency', + 'reconciliation_status', 'reconciled_amount', + 'cbs_data', 'lending', 'metadata', + 'canceled_at', 'updated_at', 'created_at' + ) + end + + it 'declares a ManyToOne relation to connected_account via connected_account_id' do + rel = collection.schema[:fields]['connected_account'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('connected_account_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'exposes only connected_account as a typed relation' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account') + end + + it 'marks every column as read-only (payment captures are PSP-emitted)' do + f = collection.schema[:fields] + %w[id type source amount currency date value_date reconciliation_status + reconciled_amount metadata canceled_at updated_at created_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'exposes type, source and reconciliation_status as Enum columns with Numeral values' do + f = collection.schema[:fields] + expect(f['type'].column_type).to eq('Enum') + expect(f['type'].enum_values).to contain_exactly('charge', 'chargeback', 'refund') + expect(f['source'].column_type).to eq('Enum') + expect(f['source'].enum_values).to contain_exactly('api', 'reporting_file') + expect(f['reconciliation_status'].column_type).to eq('Enum') + expect(f['reconciliation_status'].enum_values) + .to contain_exactly('unreconciled', 'reconciled', 'partially_reconciled') + end + + it 'keeps cbs_data, lending and metadata as Json' do + f = collection.schema[:fields] + expect(f['cbs_data'].column_type).to eq('Json') + expect(f['lending'].column_type).to eq('Json') + expect(f['metadata'].column_type).to eq('Json') + end + + it 'does not implement create / update / delete' do + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(payment_capture) + expect(result).to include( + 'id' => 'pc1', 'type' => 'charge', 'source' => 'reporting_file', + 'amount' => 2000, 'currency' => 'EUR', + 'connected_account_id' => 'acc1', 'reconciliation_status' => 'unreconciled' + ) + end + + it 'keeps cbs_data, lending and metadata as embedded Json snapshots' do + result = collection.serialize(payment_capture) + expect(result['cbs_data']).to eq('sys' => 'core') + expect(result['lending']).to eq('loan_ids' => []) + expect(result['metadata']).to eq('src' => 'sandbox') + end + end + + describe '#list' do + it 'returns rows without resolving the relation when projection has no relation prefix' do + allow(client).to receive(:list_payment_captures).and_return([payment_capture]) + allow(client).to receive(:find_connected_account) + + rows = collection.list(nil, Filter.new, %w[id type amount]) + + expect(rows).to eq([{ 'id' => 'pc1', 'type' => 'charge', 'amount' => 2000 }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account when the projection asks for it' do + allow(client).to receive(:list_payment_captures).and_return([payment_capture]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + + expect(rows.first['connected_account']).to include('id' => 'acc1', 'name' => 'Acme') + end + + it 'short-circuits to find_payment_capture on id lookup' do + allow(client).to receive(:find_payment_capture).with('pc1').and_return(payment_capture) + allow(client).to receive(:list_payment_captures) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'pc1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_payment_capture).with('pc1') + expect(client).not_to have_received(:list_payment_captures) + end + + it 'drops 404 (nil) records from the result on id lookup' do + allow(client).to receive(:find_payment_capture).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'forwards translated connected_account_id, type, source and reconciliation_status filters' do + allow(client).to receive(:list_payment_captures).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('type', 'equal', 'refund')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payment_captures).with(hash_including('type' => 'refund')) + + filter = Filter.new(condition_tree: Leaf.new('source', 'equal', 'reporting_file')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payment_captures) + .with(hash_including('source' => 'reporting_file')) + + filter = Filter.new(condition_tree: Leaf.new('reconciliation_status', 'equal', 'reconciled')) + collection.list(nil, filter, ['id']) + expect(client).to have_received(:list_payment_captures) + .with(hash_including('reconciliation_status' => 'reconciled')) + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_payment_captures) + + filter = Filter.new(condition_tree: Leaf.new('merchant_id', 'equal', 'M-1')) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'merchant_id'/) + expect(client).not_to have_received(:list_payment_captures) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_order_spec.rb new file mode 100644 index 000000000..422a9b11a --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_order_spec.rb @@ -0,0 +1,202 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::PaymentOrder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:ea_collection) { Collections::ExternalAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:external_account) { { 'id' => 'ea1', 'name' => 'Receiver' } } + let(:payment_order) do + { + 'id' => 'po1', 'object' => 'payment_order', + 'connected_account_id' => 'acc1', + 'receiving_account_id' => 'ea1', + 'type' => 'sepa_instant', 'direction' => 'credit', + 'status' => 'sent', 'amount' => 1000, 'currency' => 'EUR', + 'reference' => 'REF', 'purpose' => '', 'end_to_end_id' => 'e2e', + 'originating_account' => { 'account_number' => 'BE..' }, + 'receiving_account' => { 'account_number' => 'NL..' }, + 'created_at' => '2026-05-04T08:50:28Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + allow(datasource).to receive(:get_collection).with('MambuExternalAccount').and_return(ea_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'receiving_account_id', + 'type', 'direction', 'status', 'amount', + 'currency', 'reference', 'purpose', 'end_to_end_id', 'idempotency_key', + 'value_date', 'initiated_at', 'requested_execution_date', + 'reconciliation_status', 'reconciled_amount', + 'originating_account', 'receiving_account', 'metadata', 'custom_fields', + 'created_at' + ) + end + + it 'declares a ManyToOne relation to connected_account via connected_account_id' do + rel = collection.schema[:fields]['connected_account'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('connected_account_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'declares a ManyToOne relation to external_account via receiving_account_id' do + rel = collection.schema[:fields]['external_account'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('receiving_account_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'marks receiving_account_id as read-only (set server-side at creation)' do + expect(collection.schema[:fields]['receiving_account_id'].is_read_only).to be(true) + end + + it 'keeps originating_account / receiving_account as Json (embedded snapshots)' do + f = collection.schema[:fields] + expect(f['originating_account'].column_type).to eq('Json') + expect(f['receiving_account'].column_type).to eq('Json') + end + end + + describe '#list' do + it 'returns rows without resolving the relation when projection has no relation prefix' do + allow(client).to receive(:list_payment_orders).and_return([payment_order]) + allow(client).to receive(:find_connected_account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account_id']) + + expect(rows).to eq([{ 'id' => 'po1', 'connected_account_id' => 'acc1' }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account when the projection asks for it' do + allow(client).to receive(:list_payment_orders).and_return([payment_order]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + + expect(rows.first['connected_account']).to include('id' => 'acc1', 'name' => 'Acme') + end + + it 'fetches a unique FK only once across multiple rows' do + allow(client).to receive(:list_payment_orders).and_return([payment_order, payment_order, payment_order]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + collection.list(nil, Filter.new, ['id', 'connected_account:name']) + + expect(client).to have_received(:find_connected_account).with('acc1').once + end + + it 'short-circuits to find_payment_order on id lookup' do + allow(client).to receive(:find_payment_order).with('po1').and_return(payment_order) + allow(client).to receive(:list_payment_orders) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'po1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_payment_order).with('po1') + expect(client).not_to have_received(:list_payment_orders) + end + + it 'forwards a translated connected_account_id filter to the API' do + allow(client).to receive(:list_payment_orders).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('connected_account_id', 'equal', 'acc1')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_payment_orders) + .with(hash_including('connected_account_id' => 'acc1')) + end + + it 'forwards receiving_account_id as external_account_id (Numeral list param)' do + allow(client).to receive(:list_payment_orders).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('receiving_account_id', 'equal', 'ea1')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_payment_orders) + .with(hash_including('external_account_id' => 'ea1')) + end + + it 'embeds external_account when the projection asks for it' do + allow(client).to receive(:list_payment_orders).and_return([payment_order]) + allow(client).to receive(:find_external_account).with('ea1').and_return(external_account) + + rows = collection.list(nil, Filter.new, ['id', 'external_account:name']) + + expect(rows.first['external_account']).to include('id' => 'ea1', 'name' => 'Receiver') + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_payment_orders) + + filter = Filter.new(condition_tree: Leaf.new('status', 'equal', 'pending_approval')) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'status'/) + expect(client).not_to have_received(:list_payment_orders) + end + end + + describe '#create' do + it 'strips system-managed fields before POSTing' do + allow(client).to receive(:create_payment_order) do |payload| + expect(payload).to include('amount' => 1000) + expect(payload.keys).not_to include('id', 'status', 'created_at', 'value_date', 'initiated_at', + 'reconciliation_status', 'reconciled_amount', + 'receiving_account_id') + { 'id' => 'po1', 'connected_account_id' => 'acc1', 'amount' => 1000 } + end + + collection.create(nil, + 'id' => 'ignored', 'status' => 'sent', + 'created_at' => 't', 'value_date' => 't', + 'initiated_at' => 't', 'reconciliation_status' => 'r', + 'reconciled_amount' => 0, 'amount' => 1000, + 'receiving_account_id' => 'ea-ignored') + + expect(client).to have_received(:create_payment_order) + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter' do + allow(client).to receive(:find_payment_order).with('a').and_return('id' => 'a') + allow(client).to receive(:find_payment_order).with('b').and_return('id' => 'b') + allow(client).to receive(:update_payment_order) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'amount' => 200) + + expect(client).to have_received(:update_payment_order).with('a', hash_including('amount' => 200)) + expect(client).to have_received(:update_payment_order).with('b', hash_including('amount' => 200)) + end + end + + describe '#delete' do + it 'DELETEs each id resolved by the filter' do + allow(client).to receive(:find_payment_order).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_payment_order) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_payment_order).with('a') + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/reconciliation_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/reconciliation_spec.rb new file mode 100644 index 000000000..45edb74d3 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/reconciliation_spec.rb @@ -0,0 +1,201 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Reconciliation do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:tx_collection) { Collections::Transaction.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:transaction) { { 'id' => 'tx1', 'amount' => 3000 } } + let(:reconciliation) do + { + 'id' => 'rec1', 'object' => 'reconciliation', + 'transaction_id' => 'tx1', + 'payment_id' => 'po1', 'payment_type' => 'payment_order', + 'amount' => 3000, 'match_type' => 'manual', + 'metadata' => { 'note' => 'manual match' }, + 'canceled_at' => nil, + 'created_at' => '2026-05-20T08:00:00Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuTransaction').and_return(tx_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'object', 'transaction_id', 'payment_id', 'payment_type', + 'amount', 'match_type', 'metadata', 'canceled_at', 'created_at' + ) + end + + it 'declares a ManyToOne relation to transaction via transaction_id' do + rel = collection.schema[:fields]['transaction'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('transaction_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'does not expose a typed relation for payment_id (polymorphic in Numeral)' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('transaction') + end + + it 'exposes payment_type and match_type as Enum columns with Numeral values' do + f = collection.schema[:fields] + expect(f['payment_type'].column_type).to eq('Enum') + expect(f['payment_type'].enum_values).to contain_exactly( + 'payment_order', 'incoming_payment', 'return', 'expected_payment', 'payment_capture' + ) + expect(f['match_type'].column_type).to eq('Enum') + expect(f['match_type'].enum_values).to contain_exactly('manual', 'auto') + end + + it 'marks system-managed columns as read-only' do + f = collection.schema[:fields] + %w[id object payment_type match_type canceled_at created_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'keeps transaction_id, payment_id, amount and metadata writable on create' do + f = collection.schema[:fields] + %w[transaction_id payment_id amount metadata].each do |k| + expect(f[k].is_read_only).to be(false), "#{k} should be writable" + end + end + + it 'does not implement delete (Numeral has no DELETE on /reconciliations)' do + expect(collection.public_methods(false)).not_to include(:delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(reconciliation) + expect(result).to include( + 'id' => 'rec1', 'transaction_id' => 'tx1', + 'payment_id' => 'po1', 'payment_type' => 'payment_order', + 'amount' => 3000, 'match_type' => 'manual' + ) + end + end + + describe '#list' do + it 'returns rows without resolving the relation when projection has no relation prefix' do + allow(client).to receive(:list_reconciliations).and_return([reconciliation]) + allow(client).to receive(:find_transaction) + + rows = collection.list(nil, Filter.new, ['id', 'transaction_id']) + + expect(rows).to eq([{ 'id' => 'rec1', 'transaction_id' => 'tx1' }]) + expect(client).not_to have_received(:find_transaction) + end + + it 'embeds transaction when the projection asks for it' do + allow(client).to receive(:list_reconciliations).and_return([reconciliation]) + allow(client).to receive(:find_transaction).with('tx1').and_return(transaction) + + rows = collection.list(nil, Filter.new, ['id', 'transaction:id']) + + expect(rows.first['transaction']).to include('id' => 'tx1') + end + + it 'short-circuits to find_reconciliation on id lookup' do + allow(client).to receive(:find_reconciliation).with('rec1').and_return(reconciliation) + allow(client).to receive(:list_reconciliations) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'rec1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_reconciliation).with('rec1') + expect(client).not_to have_received(:list_reconciliations) + end + + it 'drops 404 (nil) records from the result on id lookup' do + allow(client).to receive(:find_reconciliation).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'forwards a translated transaction_id filter to the API' do + allow(client).to receive(:list_reconciliations).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('transaction_id', 'equal', 'tx1')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_reconciliations) + .with(hash_including('transaction_id' => 'tx1')) + end + + it 'forwards payment_id, payment_type and match_type filters to the API' do + allow(client).to receive(:list_reconciliations).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('match_type', 'equal', 'auto')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_reconciliations).with(hash_including('match_type' => 'auto')) + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_reconciliations) + + filter = Filter.new(condition_tree: Leaf.new('amount', 'equal', 3000)) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'amount'/) + expect(client).not_to have_received(:list_reconciliations) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_reconciliation) do |payload| + expect(payload).to include('transaction_id' => 'tx1', 'payment_id' => 'po1', 'amount' => 3000) + expect(payload.keys).not_to include('id', 'object', 'match_type', 'canceled_at', 'created_at') + { 'id' => 'rec1', 'transaction_id' => 'tx1', 'payment_id' => 'po1' } + end + + result = collection.create(nil, + 'id' => 'ignored', 'object' => 'reconciliation', + 'transaction_id' => 'tx1', 'payment_id' => 'po1', + 'amount' => 3000, + 'match_type' => 'auto', 'canceled_at' => 't', + 'created_at' => 't', + 'metadata' => { 'src' => 'qa' }) + expect(result['id']).to eq('rec1') + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter with the writable subset only' do + allow(client).to receive(:find_reconciliation).with('a').and_return('id' => 'a') + allow(client).to receive(:find_reconciliation).with('b').and_return('id' => 'b') + allow(client).to receive(:update_reconciliation) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'metadata' => { 'note' => 'updated' }, + 'created_at' => 'ignored', + 'match_type' => 'ignored') + + %w[a b].each do |id| + expect(client).to have_received(:update_reconciliation) + .with(id, hash_including('metadata' => { 'note' => 'updated' })) + expect(client).to have_received(:update_reconciliation) + .with(id, hash_excluding('created_at', 'match_type')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/return_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/return_spec.rb new file mode 100644 index 000000000..445b9f8e9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/return_spec.rb @@ -0,0 +1,225 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Return do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:return_record) do + { + 'id' => 'ret1', 'object' => 'return', + 'connected_account_id' => 'acc1', + 'related_payment_id' => 'ip1', 'related_payment_type' => 'incoming_payment', + 'related_payment_suspended' => false, + 'return_type' => 'return', 'type' => 'sepa', 'direction' => 'debit', + 'status' => 'pending', 'status_details' => nil, + 'return_reason' => 'AC06', + 'amount' => 3000, 'currency' => 'EUR', + 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, + 'value_date' => '2026-05-20', 'booking_date' => '2026-05-20', + 'originating_account' => { 'account_number' => 'FR..' }, + 'receiving_account' => { 'account_number' => 'DE..' }, + 'aggregation_reference' => nil, 'file_id' => nil, 'metadata' => {}, + 'created_at' => '2026-05-20T08:00:00Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'related_payment_id', 'related_payment_type', + 'related_payment_suspended', 'return_type', 'type', 'direction', + 'status', 'status_details', 'return_reason', + 'amount', 'currency', 'reconciliation_status', 'reconciled_amount', + 'value_date', 'booking_date', 'originating_account', 'receiving_account', + 'aggregation_reference', 'file_id', 'metadata', 'created_at' + ) + end + + it 'declares a ManyToOne relation to connected_account via connected_account_id' do + rel = collection.schema[:fields]['connected_account'] + expect(rel).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(rel.foreign_key).to eq('connected_account_id') + expect(rel.foreign_key_target).to eq('id') + end + + it 'does not expose a typed relation for related_payment_id (polymorphic in Numeral)' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account') + end + + it 'marks system-managed columns as read-only' do + f = collection.schema[:fields] + %w[id connected_account_id related_payment_type return_type type direction + amount currency reconciliation_status reconciled_amount value_date booking_date + originating_account receiving_account aggregation_reference file_id created_at].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'keeps return_reason, status, status_details and metadata writable' do + f = collection.schema[:fields] + %w[return_reason status status_details metadata related_payment_id related_payment_suspended].each do |k| + expect(f[k].is_read_only).to be(false), "#{k} should be writable" + end + end + + it 'does not implement delete (Numeral has no DELETE on /returns)' do + expect(collection.public_methods(false)).not_to include(:delete) + end + end + + describe '#serialize' do + it 'maps the API record to a flat hash with the schema fields' do + result = collection.serialize(return_record) + expect(result).to include( + 'id' => 'ret1', 'connected_account_id' => 'acc1', + 'related_payment_id' => 'ip1', 'related_payment_type' => 'incoming_payment', + 'return_reason' => 'AC06', 'status' => 'pending', + 'amount' => 3000, 'currency' => 'EUR' + ) + end + end + + describe '#list' do + it 'returns rows without resolving the relation when projection has no relation prefix' do + allow(client).to receive(:list_returns).and_return([return_record]) + allow(client).to receive(:find_connected_account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account_id']) + + expect(rows).to eq([{ 'id' => 'ret1', 'connected_account_id' => 'acc1' }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account when the projection asks for it' do + allow(client).to receive(:list_returns).and_return([return_record]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + + expect(rows.first['connected_account']).to include('id' => 'acc1', 'name' => 'Acme') + end + + it 'short-circuits to find_return on id lookup' do + allow(client).to receive(:find_return).with('ret1').and_return(return_record) + allow(client).to receive(:list_returns) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'ret1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_return).with('ret1') + expect(client).not_to have_received(:list_returns) + end + + it 'drops 404 (nil) records from the result on id lookup' do + allow(client).to receive(:find_return).and_return(nil) + filter = Filter.new(condition_tree: Leaf.new('id', 'in', %w[missing])) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'forwards a translated related_payment_id filter to the API' do + allow(client).to receive(:list_returns).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('related_payment_id', 'equal', 'ip1')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_returns) + .with(hash_including('related_payment_id' => 'ip1')) + end + + it 'forwards status and connected_account_id filters to the API' do + allow(client).to receive(:list_returns).and_return([]) + + filter = Filter.new(condition_tree: Leaf.new('status', 'equal', 'executed')) + collection.list(nil, filter, ['id']) + + expect(client).to have_received(:list_returns).with(hash_including('status' => 'executed')) + end + + it 'raises a clear error on an undeclared filter rather than silently dropping it' do + allow(client).to receive(:list_returns) + + filter = Filter.new(condition_tree: Leaf.new('return_reason', 'equal', 'AC06')) + + expect { collection.list(nil, filter, ['id']) } + .to raise_error(UnsupportedOperatorError, /'return_reason'/) + expect(client).not_to have_received(:list_returns) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_return) do |payload| + expect(payload).to include('related_payment_id' => 'ip1', 'return_reason' => 'AC06') + expect(payload.keys).not_to include( + 'id', 'object', 'connected_account_id', 'related_payment_type', + 'return_type', 'type', 'direction', 'amount', 'currency', + 'reconciliation_status', 'reconciled_amount', + 'value_date', 'booking_date', + 'originating_account', 'receiving_account', + 'aggregation_reference', 'file_id', 'created_at' + ) + { 'id' => 'ret1', 'related_payment_id' => 'ip1', 'return_reason' => 'AC06' } + end + + result = collection.create(nil, + 'id' => 'ignored', 'object' => 'return', + 'connected_account_id' => 'acc1', + 'related_payment_id' => 'ip1', + 'related_payment_type' => 'incoming_payment', + 'return_type' => 'return', 'type' => 'sepa', + 'direction' => 'debit', + 'amount' => 3000, 'currency' => 'EUR', + 'reconciliation_status' => 'unreconciled', + 'reconciled_amount' => 0, + 'value_date' => '2026-05-20', + 'booking_date' => '2026-05-20', + 'originating_account' => { 'account_number' => 'FR..' }, + 'receiving_account' => { 'account_number' => 'DE..' }, + 'aggregation_reference' => 'agg', 'file_id' => 'f1', + 'created_at' => 't', + 'return_reason' => 'AC06', + 'related_payment_suspended' => false, + 'metadata' => { 'test' => 'true' }) + expect(result['id']).to eq('ret1') + end + end + + describe '#update' do + it 'PATCHes each id resolved by the filter with the writable subset only' do + allow(client).to receive(:find_return).with('a').and_return('id' => 'a') + allow(client).to receive(:find_return).with('b').and_return('id' => 'b') + allow(client).to receive(:update_return) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'status' => 'rejected', + 'status_details' => 'duplicate', + 'created_at' => 'ignored', + 'amount' => 9999) + + %w[a b].each do |id| + expect(client).to have_received(:update_return) + .with(id, hash_including('status' => 'rejected', 'status_details' => 'duplicate')) + expect(client).to have_received(:update_return) + .with(id, hash_excluding('created_at', 'amount')) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/transaction_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/transaction_spec.rb new file mode 100644 index 000000000..45ef323fd --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/transaction_spec.rb @@ -0,0 +1,113 @@ +module ForestAdminDatasourceMambuPayments + include ForestAdminDatasourceToolkit::Components::Query + + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + RSpec.describe Collections::Transaction do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) + end + let(:ca_collection) { Collections::ConnectedAccount.new(datasource) } + let(:ia_collection) { Collections::InternalAccount.new(datasource) } + let(:ea_collection) { Collections::ExternalAccount.new(datasource) } + let(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:transaction) do + { + 'id' => 'tx1', 'object' => 'transaction', + 'connected_account_id' => 'acc1', + 'category' => 'direct_debit', 'direction' => 'debit', + 'amount' => 5000, 'currency' => 'EUR', + 'booking_date' => '2026-05-11', 'value_date' => '2026-05-11', + 'description' => 'test', 'structured_reference' => nil, + 'internal_account' => { 'account_number' => '43244675643525' }, + 'external_account' => { 'account_number' => 'AG454545' }, + 'uetr' => nil, 'reconciliation_status' => 'unreconciled', + 'reconciled_amount' => 0, 'custom_fields' => {}, + 'bank_data' => { 'file_id' => 'f1' }, + 'created_at' => '2026-05-11T07:10:57Z' + } + end + + before do + allow(datasource).to receive(:get_collection).with('MambuConnectedAccount').and_return(ca_collection) + allow(datasource).to receive(:get_collection).with('MambuInternalAccount').and_return(ia_collection) + allow(datasource).to receive(:get_collection).with('MambuExternalAccount').and_return(ea_collection) + end + + describe 'schema' do + it 'declares the API-aligned columns' do + keys = collection.schema[:fields].keys + expect(keys).to include( + 'id', 'connected_account_id', 'category', 'direction', 'amount', 'currency', + 'description', 'structured_reference', 'value_date', 'booking_date', + 'internal_account_id', 'external_account_id', + 'uetr', 'bank_data', 'reconciliation_status', 'reconciled_amount', + 'custom_fields', 'created_at' + ) + end + + it 'does not expose the removed counterparty_* / status / payment_order_id fields' do + keys = collection.schema[:fields].keys + %w[type counterparty_name counterparty_iban counterparty_bic status payment_order_id end_to_end_id] + .each { |k| expect(keys).not_to include(k), "schema unexpectedly exposes #{k}" } + end + + it 'does not expose internal_account_snapshot / external_account_snapshot (replaced by ManyToOne relations)' do + keys = collection.schema[:fields].keys + %w[internal_account_snapshot external_account_snapshot].each do |k| + expect(keys).not_to include(k), "schema unexpectedly exposes #{k}" + end + end + + it 'declares ManyToOne to connected_account, internal_account and external_account (no payment_order)' do + rels = collection.schema[:fields].select do |_, v| + v.is_a?(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + expect(rels.keys).to contain_exactly('connected_account', 'internal_account', 'external_account') + end + + it 'marks every column as read-only (Numeral transactions are immutable)' do + f = collection.schema[:fields] + %w[category direction amount currency description bank_data].each do |k| + expect(f[k].is_read_only).to be(true), "#{k} should be read-only" + end + end + + it 'does not implement create / update / delete' do + # Numeral does not let consumers mutate transactions. + expect(collection.respond_to?(:create) && collection.method(:create).source_location.nil?).to be_falsey + expect(collection.public_methods(false)).not_to include(:create, :update, :delete) + end + end + + describe '#list' do + it 'embeds connected_account when requested by the projection' do + allow(client).to receive(:list_transactions).and_return([transaction]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) + expect(rows.first['connected_account']).to include('name' => 'Acme') + end + + it 'short-circuits to find_transaction on id lookup' do + allow(client).to receive(:find_transaction).with('tx1').and_return(transaction) + allow(client).to receive(:list_transactions) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'tx1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_transaction).with('tx1') + expect(client).not_to have_received(:list_transactions) + end + + it 'projects to the requested column subset' do + allow(client).to receive(:list_transactions).and_return([transaction]) + rows = collection.list(nil, Filter.new, %w[id category amount]) + expect(rows.first).to eq('id' => 'tx1', 'category' => 'direct_debit', 'amount' => 5000) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/configuration_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/configuration_spec.rb new file mode 100644 index 000000000..2ecef3bc5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/configuration_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Configuration do + let(:valid_args) { { api_key: 'sk_test_xyz' } } + + describe '#initialize' do + it 'accepts a valid api_key' do + config = described_class.new(**valid_args) + expect(config.api_key).to eq('sk_test_xyz') + end + + it 'raises a ConfigurationError when api_key is nil' do + expect { described_class.new(api_key: nil) } + .to raise_error(ForestAdminDatasourceMambuPayments::ConfigurationError, /api_key/) + end + + it 'raises a ConfigurationError when api_key is blank' do + expect { described_class.new(api_key: ' ') } + .to raise_error(ForestAdminDatasourceMambuPayments::ConfigurationError, /api_key/) + end + + it 'defaults to the production base URL' do + config = described_class.new(**valid_args) + expect(config.base_url).to eq(described_class::DEFAULT_BASE_URL) + end + + it 'switches to the sandbox base URL when sandbox: true' do + config = described_class.new(**valid_args, sandbox: true) + expect(config.base_url).to eq(described_class::SANDBOX_BASE_URL) + end + + it 'honours an explicit base_url override (sandbox flag is ignored)' do + config = described_class.new(**valid_args, base_url: 'https://example.test', sandbox: true) + expect(config.base_url).to eq('https://example.test') + end + + it 'keeps configurable open and overall timeouts' do + config = described_class.new(**valid_args, open_timeout: 1, timeout: 2) + expect(config.open_timeout).to eq(1) + expect(config.timeout).to eq(2) + end + end + + describe '#url' do + it 'appends the API version to the base URL' do + config = described_class.new(**valid_args) + expect(config.url).to eq("#{described_class::DEFAULT_BASE_URL}/v1") + end + + it 'trims a trailing slash from base_url before appending the version' do + config = described_class.new(**valid_args, base_url: 'https://example.test/') + expect(config.url).to eq('https://example.test/v1') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb new file mode 100644 index 000000000..bb4ebb02d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb @@ -0,0 +1,38 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Datasource do + let(:valid_args) { { api_key: 'k' } } + + it 'builds with valid credentials and exposes a client' do + ds = described_class.new(**valid_args) + expect(ds.client).to be_a(ForestAdminDatasourceMambuPayments::Client) + expect(ds.configuration.api_key).to eq('k') + end + + it 'forwards the sandbox flag to the configuration' do + ds = described_class.new(**valid_args, sandbox: true) + expect(ds.configuration.base_url) + .to eq(ForestAdminDatasourceMambuPayments::Configuration::SANDBOX_BASE_URL) + end + + it 'raises ConfigurationError when api_key is missing' do + expect { described_class.new(api_key: nil) } + .to raise_error(ForestAdminDatasourceMambuPayments::ConfigurationError) + end + + it 'registers all Mambu Payments collections' do + ds = described_class.new(**valid_args) + expect(ds.collections.keys).to contain_exactly( + 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', + 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', + 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', + 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim', 'MambuReconciliation', + 'MambuPaymentCapture', 'MambuPayeeVerificationRequest' + ) + end + + it 'registers no smart actions by default (actions are opt-in via plugins)' do + ds = described_class.new(**valid_args) + %w[MambuAccountHolder MambuExternalAccount MambuInternalAccount MambuPaymentOrder].each do |name| + expect(ds.get_collection(name).schema[:actions]).to be_empty + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/disable_search_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/disable_search_spec.rb new file mode 100644 index 000000000..11852fe8d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/disable_search_spec.rb @@ -0,0 +1,46 @@ +module ForestAdminDatasourceMambuPayments + module DisableSearchSupport + # Records whether search was disabled. + class FakeSearchCollection + attr_reader :search_disabled + + def initialize + @search_disabled = false + end + + def disable_search + @search_disabled = true + end + end + + class FakeSearchDatasourceCustomizer + attr_reader :collections + + def initialize + @collections = Hash.new { |hash, key| hash[key] = FakeSearchCollection.new } + end + + def customize_collection(name) + yield(@collections[name]) + end + end + end + + RSpec.describe Plugins::DisableSearch do + subject(:plugin) { described_class.new } + + let(:customizer) { DisableSearchSupport::FakeSearchDatasourceCustomizer.new } + + it 'disables search on every Mambu collection' do + plugin.run(customizer) + + expect(customizer.collections.keys).to match_array(described_class::COLLECTIONS) + expect(customizer.collections.values).to all(have_attributes(search_disabled: true)) + end + + it 'raises when installed on a single collection instead of the datasource' do + expect { plugin.run(nil) } + .to raise_error(ArgumentError, /must be installed at the datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/helpers_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/helpers_spec.rb new file mode 100644 index 000000000..8643d1e81 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/helpers_spec.rb @@ -0,0 +1,113 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Helpers do + let(:described) { described_class } + + describe '.normalize_scopes' do + it 'defaults to both single and bulk when value is nil' do + expect(described.normalize_scopes(nil)).to contain_exactly(:single, :bulk) + end + + it 'accepts strings and symbols interchangeably' do + expect(described.normalize_scopes(%w[single])).to eq([:single]) + expect(described.normalize_scopes([:bulk])).to eq([:bulk]) + end + + it 'dedupes repeated values' do + expect(described.normalize_scopes(%i[single single bulk])).to contain_exactly(:single, :bulk) + end + + it 'raises a ForestException on unknown scopes' do + expect { described.normalize_scopes(%i[single global]) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /Unknown scopes: global/) + end + end + + describe '.resolve_ids' do + let(:context) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(records: records) } + + context 'with a list of records' do + let(:records) { [{ 'foo_id' => 'a' }, { 'foo_id' => 'b' }] } + + it 'returns the value of the configured field for each record' do + expect(described.resolve_ids(context, 'foo_id')).to eq(%w[a b]) + end + end + + it 'accepts symbol keys on the host record' do + records = [{ foo_id: 'sym' }] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(records: records) + expect(described.resolve_ids(context, 'foo_id')).to eq(['sym']) + end + + it 'skips records without a value (nil or missing)' do + records = [{ 'foo_id' => 'a' }, { 'foo_id' => nil }, { 'other' => 'b' }] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(records: records) + expect(described.resolve_ids(context, 'foo_id')).to eq(['a']) + end + + it 'logs and returns [] when get_records raises' do + ctx = instance_double(ForestAdminDatasourceCustomizer::Decorators::Action::Context::ActionContextSingle) + allow(ctx).to receive(:get_records).and_raise(StandardError, 'boom') + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + + expect(described.resolve_ids(ctx, 'foo_id')).to eq([]) + expect(ForestAdminDatasourceMambuPayments.logger).to have_received(:warn) + .with(a_string_including('foo_id', 'boom')) + end + end + + describe '.each_with_rescue' do + it 'yields once per id and returns succeeded/failed splits' do + succeeded, failed = described.each_with_rescue(%w[a b c], 'op') { |id| raise StandardError, 'x' if id == 'b' } + expect(succeeded).to eq(%w[a c]) + expect(failed.map(&:first)).to eq(%w[b]) + end + + it 'logs failures with class and message' do + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + described.each_with_rescue(['x'], 'op') { raise StandardError, 'boom' } # rubocop:disable Lint/UnreachableLoop + expect(ForestAdminDatasourceMambuPayments.logger).to have_received(:warn) + .with(a_string_including('op', '#x', 'boom')) + end + end + + describe '.to_int' do + it 'parses integer strings' do + expect(described.to_int('42')).to eq(42) + end + + it 'returns nil for blank or non-numeric values' do + expect(described.to_int(nil)).to be_nil + expect(described.to_int('')).to be_nil + expect(described.to_int('not a number')).to be_nil + end + end + + describe '.write_back' do + let(:collection) { instance_double('Collection', update: true) } # rubocop:disable RSpec/VerifiedDoubleReference + let(:context) do + ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + collection: collection, filter: :stub_filter + ) + end + + it 'returns :skipped when the field or value is nil' do + expect(described.write_back(context, nil, 'v')).to eq(:skipped) + expect(described.write_back(context, 'field', nil)).to eq(:skipped) + end + + it 'calls collection.update with the right payload on success' do + expect(described.write_back(context, 'mambu_id', 'abc')).to eq(:ok) + expect(collection).to have_received(:update).with(:stub_filter, { 'mambu_id' => 'abc' }) + end + + it 'logs and returns [:failed, msg] when update raises' do + allow(collection).to receive(:update).and_raise(StandardError, 'db down') + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + + result = described.write_back(context, 'mambu_id', 'abc') + expect(result.first).to eq(:failed) + expect(result.last).to include('db down') + expect(ForestAdminDatasourceMambuPayments.logger).to have_received(:warn).with(a_string_including('mambu_id')) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates_spec.rb new file mode 100644 index 000000000..1b3a5d2b6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates_spec.rb @@ -0,0 +1,95 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToDirectDebitMandates do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'imports external_account:account_holder_id onto MambuDirectDebitMandate' do + install + imports = datasource_customizer.collections['MambuDirectDebitMandate'].imported_fields + expect(imports).to have_key('account_holder_id') + expect(imports['account_holder_id']).to include(path: 'external_account:account_holder_id', readonly: true) + end + + it 'adds a ManyToOne account_holder relation on MambuDirectDebitMandate' do + install + rel = datasource_customizer.collections['MambuDirectDebitMandate'].many_to_one_relations['account_holder'] + expect(rel).to include( + foreign_collection: 'MambuAccountHolder', + foreign_key: 'account_holder_id', + foreign_key_target: 'id' + ) + end + + it 'adds the reciprocal OneToMany direct_debit_mandates on MambuAccountHolder' do + install + rel = datasource_customizer.collections['MambuAccountHolder'].one_to_many_relations['direct_debit_mandates'] + expect(rel).to include( + foreign_collection: 'MambuDirectDebitMandate', + origin_key: 'account_holder_id', + origin_key_target: 'id' + ) + end + + it 'imports the field before declaring the ManyToOne (the relation depends on the imported column)' do + ddm = datasource_customizer.collections['MambuDirectDebitMandate'] + call_order = [] + allow(ddm).to receive(:import_field).and_wrap_original do |orig, *args, **kw| + call_order << :import + orig.call(*args, **kw) + end + allow(ddm).to receive(:add_many_to_one_relation).and_wrap_original do |orig, *args, **kw| + call_order << :many_to_one + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order).to eq(%i[import many_to_one]) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on account_holder_id' do + let(:ddm) { install['MambuDirectDebitMandate'] } + + it 'registers an EQUAL and IN handler on account_holder_id' do + expect(ddm.operator_handlers.keys).to include( + %w[account_holder_id equal], %w[account_holder_id in] + ) + end + + it 'rewrites EQUAL holder_id to external_account_id IN (resolved ids)' do + handler = ddm.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuExternalAccount' => [{ 'id' => 'ea1' }, { 'id' => 'ea2' }] + ) + + result = handler.call('holder-1', ctx) + + expect(result.field).to eq('external_account_id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ea1', 'ea2') + end + + it 'returns a no-match sentinel leaf when the holder has no external accounts' do + handler = ddm.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuExternalAccount' => [] + ) + + result = handler.call('holder-without-accounts', ctx) + + expect(result.field).to eq('external_account_id') + expect(result.operator).to eq('equal') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments_spec.rb new file mode 100644 index 000000000..765fcd33e --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments_spec.rb @@ -0,0 +1,116 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToIncomingPayments do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'imports internal_account:account_holder_id onto MambuIncomingPayment' do + install + imports = datasource_customizer.collections['MambuIncomingPayment'].imported_fields + expect(imports).to have_key('account_holder_id') + expect(imports['account_holder_id']).to include(path: 'internal_account:account_holder_id', readonly: true) + end + + it 'adds a ManyToOne account_holder relation on MambuIncomingPayment' do + install + rel = datasource_customizer.collections['MambuIncomingPayment'].many_to_one_relations['account_holder'] + expect(rel).to include( + foreign_collection: 'MambuAccountHolder', + foreign_key: 'account_holder_id', + foreign_key_target: 'id' + ) + end + + it 'adds the reciprocal OneToMany incoming_payments on MambuAccountHolder' do + install + rel = datasource_customizer.collections['MambuAccountHolder'].one_to_many_relations['incoming_payments'] + expect(rel).to include( + foreign_collection: 'MambuIncomingPayment', + origin_key: 'account_holder_id', + origin_key_target: 'id' + ) + end + + it 'imports the field before declaring the ManyToOne (the relation depends on the imported column)' do + ip = datasource_customizer.collections['MambuIncomingPayment'] + call_order = [] + allow(ip).to receive(:import_field).and_wrap_original do |orig, *args, **kw| + call_order << :import + orig.call(*args, **kw) + end + allow(ip).to receive(:add_many_to_one_relation).and_wrap_original do |orig, *args, **kw| + call_order << :many_to_one + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order).to eq(%i[import many_to_one]) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on account_holder_id' do + let(:ip) { install['MambuIncomingPayment'] } + + it 'registers an EQUAL and IN handler on account_holder_id' do + expect(ip.operator_handlers.keys).to include( + %w[account_holder_id equal], %w[account_holder_id in] + ) + end + + it 'rewrites EQUAL holder_id to internal_account_id IN (resolved ids)' do + handler = ip.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [{ 'id' => 'ia1' }, { 'id' => 'ia2' }] + ) + + result = handler.call('holder-1', ctx) + + expect(result.field).to eq('internal_account_id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ia1', 'ia2') + end + + it 'rewrites IN holder_ids to internal_account_id IN (deduped resolved ids)' do + handler = ip.operator_handlers[%w[account_holder_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [{ 'id' => 'ia1' }, { 'id' => 'ia2' }, { 'id' => 'ia1' }] + ) + + result = handler.call(%w[holder-1 holder-2], ctx) + + expect(result.field).to eq('internal_account_id') + expect(result.value).to contain_exactly('ia1', 'ia2') + end + + it 'returns a no-match sentinel leaf when the holder has no internal accounts' do + handler = ip.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [] + ) + + result = handler.call('holder-without-accounts', ctx) + + expect(result.field).to eq('internal_account_id') + expect(result.operator).to eq('equal') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + + it 'returns a no-match sentinel leaf when the filter value is blank' do + handler = ip.operator_handlers[%w[account_holder_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new({}) + + result = handler.call([nil, ''], ctx) + + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates_spec.rb new file mode 100644 index 000000000..5d40cc6c8 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates_spec.rb @@ -0,0 +1,31 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToDirectDebitMandates do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany direct_debit_mandates on MambuExternalAccount via external_account_id' do + install + rel = datasource_customizer.collections['MambuExternalAccount'] + .one_to_many_relations['direct_debit_mandates'] + expect(rel).to include( + foreign_collection: 'MambuDirectDebitMandate', + origin_key: 'external_account_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuDirectDebitMandate (FK already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuExternalAccount') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments_spec.rb new file mode 100644 index 000000000..78d3f59fd --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToIncomingPayments do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany incoming_payments on MambuExternalAccount via external_account_id' do + install + rel = datasource_customizer.collections['MambuExternalAccount'].one_to_many_relations['incoming_payments'] + expect(rel).to include( + foreign_collection: 'MambuIncomingPayment', + origin_key: 'external_account_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuIncomingPayment (FK already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuExternalAccount') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders_spec.rb new file mode 100644 index 000000000..171c1ed24 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToPaymentOrders do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany payment_orders on MambuExternalAccount via receiving_account_id' do + install + rel = datasource_customizer.collections['MambuExternalAccount'].one_to_many_relations['payment_orders'] + expect(rel).to include( + foreign_collection: 'MambuPaymentOrder', + origin_key: 'receiving_account_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuPaymentOrder (FK + ManyToOne already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuExternalAccount') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events_spec.rb new file mode 100644 index 000000000..9ddc8bd97 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToEvents do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany events on MambuIncomingPayment via related_object_id' do + install + rel = datasource_customizer.collections['MambuIncomingPayment'].one_to_many_relations['events'] + expect(rel).to include( + foreign_collection: 'MambuEvent', + origin_key: 'related_object_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuEvent (api_filters live in the collection itself)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuIncomingPayment') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments_spec.rb new file mode 100644 index 000000000..109d247fc --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments_spec.rb @@ -0,0 +1,103 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToExpectedPayments do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'declares a computed incoming_payment_id column on MambuExpectedPayment' do + install + field = datasource_customizer.collections['MambuExpectedPayment'].computed_fields['incoming_payment_id'] + expect(field).not_to be_nil + expect(field.column_type).to eq('String') + expect(field.dependencies).to eq(['id']) + expect(field.get_values([{ 'id' => 'ep-1' }], nil)).to eq([nil]) + end + + it 'adds the reciprocal OneToMany matched_expected_payments on MambuIncomingPayment' do + install + rel = datasource_customizer.collections['MambuIncomingPayment'].one_to_many_relations['matched_expected_payments'] + expect(rel).to include( + foreign_collection: 'MambuExpectedPayment', + origin_key: 'incoming_payment_id', + origin_key_target: 'id' + ) + end + + it 'declares the virtual column before installing the operator rewrites' do + ep = datasource_customizer.collections['MambuExpectedPayment'] + call_order = [] + allow(ep).to receive(:add_field).and_wrap_original do |orig, *args, **kw| + call_order << :add_field + orig.call(*args, **kw) + end + allow(ep).to receive(:replace_field_operator).and_wrap_original do |orig, *args, **kw| + call_order << :replace_op + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order.first).to eq(:add_field) + expect(call_order).to include(:replace_op) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'cross-reconciliation filter rewrite on incoming_payment_id' do + let(:ep) { install['MambuExpectedPayment'] } + + it 'registers an EQUAL and IN handler on incoming_payment_id' do + expect(ep.operator_handlers.keys).to include( + %w[incoming_payment_id equal], %w[incoming_payment_id in] + ) + end + + # Records used for both passes; the helper extracts transaction_id from + # the first pass and payment_id from the second. The fake collection + # ignores filters, so a single record set with both fields populated + # works to verify the rewrite shape end-to-end. + it 'rewrites EQUAL ip_id to id IN (resolved expected_payment ids)' do + handler = ep.operator_handlers[%w[incoming_payment_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [ + { 'transaction_id' => 'tx-1', 'payment_id' => 'ep-1' }, + { 'transaction_id' => 'tx-2', 'payment_id' => 'ep-2' } + ] + ) + + result = handler.call('ip-1', ctx) + + expect(result.field).to eq('id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ep-1', 'ep-2') + end + + it 'returns the no-match sentinel when the first reconciliation pass yields no transactions' do + handler = ep.operator_handlers[%w[incoming_payment_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [] + ) + + result = handler.call('ip-unmatched', ctx) + + expect(result.field).to eq('id') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + + it 'returns the no-match sentinel when the filter value is blank' do + handler = ep.operator_handlers[%w[incoming_payment_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new({}) + + result = handler.call([nil, ''], ctx) + + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns_spec.rb new file mode 100644 index 000000000..5c80fba00 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToReturns do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany returns on MambuIncomingPayment via related_payment_id' do + install + rel = datasource_customizer.collections['MambuIncomingPayment'].one_to_many_relations['returns'] + expect(rel).to include( + foreign_collection: 'MambuReturn', + origin_key: 'related_payment_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuReturn (FK + API filter already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuIncomingPayment') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions_spec.rb new file mode 100644 index 000000000..617925aea --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions_spec.rb @@ -0,0 +1,90 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToTransactions do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'declares a computed incoming_payment_id column on MambuTransaction' do + install + field = datasource_customizer.collections['MambuTransaction'].computed_fields['incoming_payment_id'] + expect(field).not_to be_nil + expect(field.column_type).to eq('String') + expect(field.dependencies).to eq(['id']) + expect(field.get_values([{ 'id' => 'tx-1' }, { 'id' => 'tx-2' }], nil)).to eq([nil, nil]) + end + + it 'adds the reciprocal OneToMany transactions on MambuIncomingPayment' do + install + rel = datasource_customizer.collections['MambuIncomingPayment'].one_to_many_relations['transactions'] + expect(rel).to include( + foreign_collection: 'MambuTransaction', + origin_key: 'incoming_payment_id', + origin_key_target: 'id' + ) + end + + it 'declares the virtual column before installing the operator rewrites' do + tx = datasource_customizer.collections['MambuTransaction'] + call_order = [] + allow(tx).to receive(:add_field).and_wrap_original do |orig, *args, **kw| + call_order << :add_field + orig.call(*args, **kw) + end + allow(tx).to receive(:replace_field_operator).and_wrap_original do |orig, *args, **kw| + call_order << :replace_op + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order.first).to eq(:add_field) + expect(call_order).to include(:replace_op) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on incoming_payment_id' do + let(:tx) { install['MambuTransaction'] } + + it 'registers an EQUAL and IN handler on incoming_payment_id' do + expect(tx.operator_handlers.keys).to include( + %w[incoming_payment_id equal], %w[incoming_payment_id in] + ) + end + + it 'rewrites EQUAL ip_id to id IN (resolved transaction ids)' do + handler = tx.operator_handlers[%w[incoming_payment_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [ + { 'transaction_id' => 'tx-1' }, + { 'transaction_id' => 'tx-2' } + ] + ) + + result = handler.call('ip-1', ctx) + + expect(result.field).to eq('id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('tx-1', 'tx-2') + end + + it 'returns a no-match sentinel leaf when no reconciliation matches' do + handler = tx.operator_handlers[%w[incoming_payment_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [] + ) + + result = handler.call('ip-unmatched', ctx) + + expect(result.field).to eq('id') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances_spec.rb new file mode 100644 index 000000000..e300fef23 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances_spec.rb @@ -0,0 +1,69 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToBalances do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'declares a computed internal_account_id column on MambuBalance' do + install + field = datasource_customizer.collections['MambuBalance'].computed_fields['internal_account_id'] + expect(field).not_to be_nil + expect(field.column_type).to eq('String') + expect(field.dependencies).to eq(['id']) + expect(field.get_values([{ 'id' => 'b-1' }], nil)).to eq([nil]) + end + + it 'adds the reciprocal OneToMany balances on MambuInternalAccount' do + install + rel = datasource_customizer.collections['MambuInternalAccount'].one_to_many_relations['balances'] + expect(rel).to include( + foreign_collection: 'MambuBalance', + origin_key: 'internal_account_id', + origin_key_target: 'id' + ) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on internal_account_id' do + let(:bal) { install['MambuBalance'] } + + it 'registers an EQUAL and IN handler on internal_account_id' do + expect(bal.operator_handlers.keys).to include( + %w[internal_account_id equal], %w[internal_account_id in] + ) + end + + it 'rewrites EQUAL holder_id to connected_account_id IN (resolved ca ids)' do + handler = bal.operator_handlers[%w[internal_account_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [{ 'connected_account_ids' => %w[ca-1 ca-2] }] + ) + + result = handler.call('ia-1', ctx) + + expect(result.field).to eq('connected_account_id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ca-1', 'ca-2') + end + + it 'returns a no-match sentinel leaf when no internal account has connected accounts' do + handler = bal.operator_handlers[%w[internal_account_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [] + ) + + result = handler.call('ia-unknown', ctx) + + expect(result.field).to eq('connected_account_id') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments_spec.rb new file mode 100644 index 000000000..5816527a6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToIncomingPayments do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany incoming_payments on MambuInternalAccount via internal_account_id' do + install + rel = datasource_customizer.collections['MambuInternalAccount'].one_to_many_relations['incoming_payments'] + expect(rel).to include( + foreign_collection: 'MambuIncomingPayment', + origin_key: 'internal_account_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuIncomingPayment (FK + ManyToOne already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuInternalAccount') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders_spec.rb new file mode 100644 index 000000000..6a2f9c737 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders_spec.rb @@ -0,0 +1,112 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToPaymentOrders do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'declares a computed internal_account_id column on MambuPaymentOrder' do + install + field = datasource_customizer.collections['MambuPaymentOrder'].computed_fields['internal_account_id'] + expect(field).not_to be_nil + expect(field.column_type).to eq('String') + expect(field.dependencies).to eq(['id']) + expect(field.get_values([{ 'id' => 'po-1' }, { 'id' => 'po-2' }], nil)).to eq([nil, nil]) + end + + it 'adds the reciprocal OneToMany payment_orders on MambuInternalAccount' do + install + rel = datasource_customizer.collections['MambuInternalAccount'].one_to_many_relations['payment_orders'] + expect(rel).to include( + foreign_collection: 'MambuPaymentOrder', + origin_key: 'internal_account_id', + origin_key_target: 'id' + ) + end + + it 'declares the virtual column before installing the operator rewrites' do + po = datasource_customizer.collections['MambuPaymentOrder'] + call_order = [] + allow(po).to receive(:add_field).and_wrap_original do |orig, *args, **kw| + call_order << :add_field + orig.call(*args, **kw) + end + allow(po).to receive(:replace_field_operator).and_wrap_original do |orig, *args, **kw| + call_order << :replace_op + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order.first).to eq(:add_field) + expect(call_order).to include(:replace_op) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on internal_account_id' do + let(:po) { install['MambuPaymentOrder'] } + + it 'registers an EQUAL and IN handler on internal_account_id' do + expect(po.operator_handlers.keys).to include( + %w[internal_account_id equal], %w[internal_account_id in] + ) + end + + it 'rewrites EQUAL holder_id to connected_account_id IN (resolved ca ids)' do + handler = po.operator_handlers[%w[internal_account_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [{ 'connected_account_ids' => %w[ca-1 ca-2] }] + ) + + result = handler.call('ia-1', ctx) + + expect(result.field).to eq('connected_account_id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ca-1', 'ca-2') + end + + it 'rewrites IN holder_ids to connected_account_id IN (flattened, deduped ca ids)' do + handler = po.operator_handlers[%w[internal_account_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [ + { 'connected_account_ids' => %w[ca-1 ca-2] }, + { 'connected_account_ids' => %w[ca-2 ca-3] } + ] + ) + + result = handler.call(%w[ia-1 ia-2], ctx) + + expect(result.field).to eq('connected_account_id') + expect(result.value).to contain_exactly('ca-1', 'ca-2', 'ca-3') + end + + it 'returns a no-match sentinel leaf when no internal account has connected accounts' do + handler = po.operator_handlers[%w[internal_account_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuInternalAccount' => [{ 'connected_account_ids' => [] }] + ) + + result = handler.call('ia-empty', ctx) + + expect(result.field).to eq('connected_account_id') + expect(result.operator).to eq('equal') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + + it 'returns a no-match sentinel leaf when the filter value is blank' do + handler = po.operator_handlers[%w[internal_account_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new({}) + + result = handler.call([nil, ''], ctx) + + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events_spec.rb new file mode 100644 index 000000000..31a8ff6cc --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToEvents do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany events on MambuPaymentOrder via related_object_id' do + install + rel = datasource_customizer.collections['MambuPaymentOrder'].one_to_many_relations['events'] + expect(rel).to include( + foreign_collection: 'MambuEvent', + origin_key: 'related_object_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuEvent (api_filters live in the collection itself)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuPaymentOrder') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder_spec.rb new file mode 100644 index 000000000..b9f141147 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder_spec.rb @@ -0,0 +1,94 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReceivingAccountHolder do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'imports external_account:account_holder_id onto MambuPaymentOrder' do + install + imports = datasource_customizer.collections['MambuPaymentOrder'].imported_fields + expect(imports).to have_key('account_holder_id') + expect(imports['account_holder_id']).to include(path: 'external_account:account_holder_id', readonly: true) + end + + it 'adds a ManyToOne receiving_account_holder relation on MambuPaymentOrder' do + install + rel = datasource_customizer.collections['MambuPaymentOrder'].many_to_one_relations['receiving_account_holder'] + expect(rel).to include( + foreign_collection: 'MambuAccountHolder', + foreign_key: 'account_holder_id', + foreign_key_target: 'id' + ) + end + + it 'adds the reciprocal OneToMany payment_orders on MambuAccountHolder' do + install + rel = datasource_customizer.collections['MambuAccountHolder'].one_to_many_relations['payment_orders'] + expect(rel).to include( + foreign_collection: 'MambuPaymentOrder', + origin_key: 'account_holder_id', + origin_key_target: 'id' + ) + end + + it 'imports the field before declaring the ManyToOne (the relation depends on the imported column)' do + po = datasource_customizer.collections['MambuPaymentOrder'] + call_order = [] + allow(po).to receive(:import_field).and_wrap_original do |orig, *args, **kw| + call_order << :import + orig.call(*args, **kw) + end + allow(po).to receive(:add_many_to_one_relation).and_wrap_original do |orig, *args, **kw| + call_order << :many_to_one + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order).to eq(%i[import many_to_one]) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on account_holder_id' do + let(:po) { install['MambuPaymentOrder'] } + + it 'registers an EQUAL and IN handler on account_holder_id' do + expect(po.operator_handlers.keys).to include( + %w[account_holder_id equal], %w[account_holder_id in] + ) + end + + it 'rewrites EQUAL holder_id to receiving_account_id IN (resolved ids)' do + handler = po.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuExternalAccount' => [{ 'id' => 'ea-1' }, { 'id' => 'ea-2' }] + ) + + result = handler.call('holder-1', ctx) + + expect(result.field).to eq('receiving_account_id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('ea-1', 'ea-2') + end + + it 'returns a no-match sentinel leaf when the holder has no external accounts' do + handler = po.operator_handlers[%w[account_holder_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuExternalAccount' => [] + ) + + result = handler.call('orphan-holder', ctx) + + expect(result.field).to eq('receiving_account_id') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns_spec.rb new file mode 100644 index 000000000..4df4ee901 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReturns do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install + described_class.new.run(datasource_customizer, nil, {}) + datasource_customizer.collections + end + + describe '#run' do + it 'adds OneToMany returns on MambuPaymentOrder via related_payment_id' do + install + rel = datasource_customizer.collections['MambuPaymentOrder'].one_to_many_relations['returns'] + expect(rel).to include( + foreign_collection: 'MambuReturn', + origin_key: 'related_payment_id', + origin_key_target: 'id' + ) + end + + it 'does not customize MambuReturn (FK + API filter already native)' do + install + expect(datasource_customizer.collections.keys).to contain_exactly('MambuPaymentOrder') + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions_spec.rb new file mode 100644 index 000000000..f721cd31c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions_spec.rb @@ -0,0 +1,115 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToTransactions do + let(:datasource_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeDatasourceCustomizer.new } + + def install(opts = {}) + described_class.new.run(datasource_customizer, nil, opts) + datasource_customizer.collections + end + + describe '#run' do + it 'declares a computed payment_order_id column on MambuTransaction' do + install + field = datasource_customizer.collections['MambuTransaction'].computed_fields['payment_order_id'] + expect(field).not_to be_nil + expect(field.column_type).to eq('String') + expect(field.dependencies).to eq(['id']) + expect(field.get_values([{ 'id' => 'tx-1' }, { 'id' => 'tx-2' }], nil)).to eq([nil, nil]) + end + + it 'adds the reciprocal OneToMany transactions on MambuPaymentOrder' do + install + rel = datasource_customizer.collections['MambuPaymentOrder'].one_to_many_relations['transactions'] + expect(rel).to include( + foreign_collection: 'MambuTransaction', + origin_key: 'payment_order_id', + origin_key_target: 'id' + ) + end + + it 'declares the virtual column before installing the operator rewrites' do + tx = datasource_customizer.collections['MambuTransaction'] + call_order = [] + allow(tx).to receive(:add_field).and_wrap_original do |orig, *args, **kw| + call_order << :add_field + orig.call(*args, **kw) + end + allow(tx).to receive(:replace_field_operator).and_wrap_original do |orig, *args, **kw| + call_order << :replace_op + orig.call(*args, **kw) + end + + described_class.new.run(datasource_customizer, nil, {}) + + expect(call_order.first).to eq(:add_field) + expect(call_order).to include(:replace_op) + end + + it 'raises ArgumentError when installed without a datasource_customizer (collection-level use)' do + expect { described_class.new.run(nil, nil, {}) } + .to raise_error(ArgumentError, /datasource level/) + end + end + + describe 'two-step filter rewrite on payment_order_id' do + let(:tx) { install['MambuTransaction'] } + + it 'registers an EQUAL and IN handler on payment_order_id' do + expect(tx.operator_handlers.keys).to include( + %w[payment_order_id equal], %w[payment_order_id in] + ) + end + + it 'rewrites EQUAL po_id to id IN (resolved transaction ids)' do + handler = tx.operator_handlers[%w[payment_order_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [ + { 'transaction_id' => 'tx-1' }, + { 'transaction_id' => 'tx-2' } + ] + ) + + result = handler.call('po-1', ctx) + + expect(result.field).to eq('id') + expect(result.operator).to eq('in') + expect(result.value).to contain_exactly('tx-1', 'tx-2') + end + + it 'rewrites IN po_ids to id IN (deduped transaction ids)' do + handler = tx.operator_handlers[%w[payment_order_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [ + { 'transaction_id' => 'tx-1' }, + { 'transaction_id' => 'tx-2' }, + { 'transaction_id' => 'tx-1' } + ] + ) + + result = handler.call(%w[po-1 po-2], ctx) + + expect(result.field).to eq('id') + expect(result.value).to contain_exactly('tx-1', 'tx-2') + end + + it 'returns a no-match sentinel leaf when no reconciliation matches' do + handler = tx.operator_handlers[%w[payment_order_id equal]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new( + 'MambuReconciliation' => [] + ) + + result = handler.call('po-unmatched', ctx) + + expect(result.field).to eq('id') + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + + it 'returns a no-match sentinel leaf when the filter value is blank' do + handler = tx.operator_handlers[%w[payment_order_id in]] + ctx = ForestAdminDatasourceMambuPayments::PluginSupport::FakeOperatorContext.new({}) + + result = handler.call([nil, ''], ctx) + + expect(result.value).to eq('00000000-0000-0000-0000-000000000000') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution_spec.rb new file mode 100644 index 000000000..b02d64acf --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution_spec.rb @@ -0,0 +1,77 @@ +module ForestAdminDatasourceMambuPayments + module PivotResolutionSupport + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + # Slices its records according to the filter's page, so the spec can prove + # PivotResolution.collect pages past one API window. + class PagingCollection + def initialize(records) + @records = records + end + + def list(filter, _projection) + page = filter.page + @records.slice(page.offset, page.limit) || [] + end + end + + class FakeContext + def initialize(records) + @paging = PagingCollection.new(records) + end + + def datasource = self + + def get_collection(_name) = @paging + end + end + + module Plugins + module Relations + RSpec.describe PivotResolution do + describe '.collect' do + it 'pages through every matching row, not just the first API page' do + records = Array.new(250) { |i| { 'transaction_id' => "tx#{i}" } } + context = PivotResolutionSupport::FakeContext.new(records) + leaf = PivotResolutionSupport::Leaf.new('payment_id', PivotResolutionSupport::Operators::IN, %w[p1]) + + ids = described_class.collect(context, 'MambuReconciliation', leaf, 'transaction_id') + + expect(ids.size).to eq(250) + expect(ids).to include('tx0', 'tx249') + end + + it 'flattens array columns and drops blank / duplicate values' do + records = [ + { 'connected_account_ids' => %w[a b] }, + { 'connected_account_ids' => ['b', '', nil] }, + { 'connected_account_ids' => ['c'] } + ] + context = PivotResolutionSupport::FakeContext.new(records) + leaf = PivotResolutionSupport::Leaf.new('id', PivotResolutionSupport::Operators::IN, %w[x]) + + ids = described_class.collect(context, 'MambuInternalAccount', leaf, 'connected_account_ids') + + expect(ids).to contain_exactly('a', 'b', 'c') + end + end + + describe '.normalize' do + it 'wraps an EQUAL scalar and drops blanks for IN' do + expect(described_class.normalize('a', PivotResolutionSupport::Operators::EQUAL)).to eq(['a']) + expect(described_class.normalize(['a', '', nil, 'a'], PivotResolutionSupport::Operators::IN)).to eq(['a']) + end + end + + describe '.no_match' do + it 'builds a leaf that matches nothing without tripping the empty-IN guard' do + leaf = described_class.no_match('connected_account_id') + expect(leaf.operator).to eq(PivotResolutionSupport::Operators::EQUAL) + expect(leaf.value).to eq(described_class::NO_MATCH_SENTINEL) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order_spec.rb new file mode 100644 index 000000000..70d9e89c5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::ApprovePaymentOrder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_payment_order_id' } + let(:action_scope) { ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered + end + + it 'registers both single and bulk by default with the right scopes' do + register + registered = collection_customizer.registered + expect(registered.keys).to contain_exactly( + 'Approve Mambu payment order', 'Approve selected Mambu payment orders' + ) + expect(registered['Approve Mambu payment order'].scope).to eq(action_scope::SINGLE) + expect(registered['Approve selected Mambu payment orders'].scope).to eq(action_scope::BULK) + end + + it 'POSTs /payment_orders/{id}/approve for each id and reports success' do + allow(client).to receive(:approve_payment_order) + bulk = register(scopes: %i[bulk])['Approve selected Mambu payment orders'] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [1, 2, 3].map { |id| { record_id_field => id } } + ) + + result = bulk.execute.call(context, result_builder) + + [1, 2, 3].each { |id| expect(client).to have_received(:approve_payment_order).with(id) } + expect(result[:message]).to include('3 payment orders approved') + end + + it 'returns an Error when all ids fail' do + allow(client).to receive(:approve_payment_order).and_raise(StandardError, 'wrong status') + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + bulk = register(scopes: %i[bulk])['Approve selected Mambu payment orders'] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'po_1' }] + ) + + result = bulk.execute.call(context, result_builder) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to include('Failed to approve', 'wrong status') + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order_spec.rb new file mode 100644 index 000000000..10ac67db3 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::CancelPaymentOrder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_payment_order_id' } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered + end + + it 'registers both single and bulk by default' do + register + expect(collection_customizer.registered.keys).to contain_exactly( + 'Cancel Mambu payment order', 'Cancel selected Mambu payment orders' + ) + end + + it 'exposes an optional Reason field on the form' do + register + action = collection_customizer.registered['Cancel Mambu payment order'] + field = action.form.first + expect(field[:label]).to eq('Reason') + expect(field[:is_required]).to be_falsey + end + + it 'POSTs /payment_orders/{id}/cancel with the reason when provided' do + allow(client).to receive(:cancel_payment_order) + single = register(scopes: %i[single])['Cancel Mambu payment order'] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'po_1' }], + form_values: { 'Reason' => 'AC01' } + ) + + single.execute.call(context, result_builder) + + expect(client).to have_received(:cancel_payment_order).with('po_1', 'reason' => 'AC01') + end + + it 'omits the reason from the payload when blank' do + allow(client).to receive(:cancel_payment_order) + single = register(scopes: %i[single])['Cancel Mambu payment order'] + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'po_1' }], form_values: { 'Reason' => '' } + ) + + single.execute.call(context, result_builder) + + expect(client).to have_received(:cancel_payment_order).with('po_1', {}) + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder_spec.rb new file mode 100644 index 000000000..6efca96f9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder_spec.rb @@ -0,0 +1,80 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::CreateAccountHolder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, { datasource: datasource }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + describe '#run' do + it 'registers a SINGLE-scoped action under the default name with a single required field' do + action = register + + expect(collection_customizer.registered.keys).to contain_exactly(described_class::NAME) + expect(action.scope).to eq(ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope::SINGLE) + expect(action.form.map { |f| f[:label] }).to eq(['Name']) + expect(action.form.first[:is_required]).to be(true) + end + + it 'honors :action_name to override the displayed name' do + register(action_name: 'New holder') + expect(collection_customizer.registered.keys).to include('New holder') + end + + it 'raises ArgumentError without :datasource' do + expect { described_class.new.run(nil, collection_customizer, {}) } + .to raise_error(ArgumentError, /datasource/) + end + + it 'raises ArgumentError without a collection_customizer' do + expect { described_class.new.run(nil, nil, datasource: datasource) } + .to raise_error(ArgumentError, /collection/) + end + end + + describe 'executor' do + it 'POSTs to /account_holders with the Name from the form and surfaces the new id' do + action = register + allow(client).to receive(:create_account_holder).and_return({ 'id' => 'ah_1' }) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(form_values: { 'Name' => 'Acme' }) + + result = action.execute.call(context, result_builder) + + expect(client).to have_received(:create_account_holder).with('name' => 'Acme') + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('Account holder #ah_1 created') + end + + it 'writes back the new id to the host record when :result_field is configured' do + action = register(result_field: 'mambu_account_holder_id') + allow(client).to receive(:create_account_holder).and_return({ 'id' => 'ah_42' }) + collection = instance_double('Collection', update: true) # rubocop:disable RSpec/VerifiedDoubleReference + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + form_values: { 'Name' => 'Acme' }, collection: collection, filter: :stub_filter + ) + + action.execute.call(context, result_builder) + + expect(collection).to have_received(:update).with(:stub_filter, 'mambu_account_holder_id' => 'ah_42') + end + + it 'surfaces a warning in the success message when the write-back fails' do + action = register(result_field: 'mambu_account_holder_id') + allow(client).to receive(:create_account_holder).and_return({ 'id' => 'ah_42' }) + collection = instance_double('Collection') # rubocop:disable RSpec/VerifiedDoubleReference + allow(collection).to receive(:update).and_raise(StandardError, 'db down') + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + form_values: { 'Name' => 'Acme' }, collection: collection, filter: :stub_filter + ) + + result = action.execute.call(context, result_builder) + + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('warning', 'db down') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account_spec.rb new file mode 100644 index 000000000..48cd7cf30 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account_spec.rb @@ -0,0 +1,41 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::CreateExternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, { datasource: datasource }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + it 'registers a SINGLE action with holder_name, account_number, bank_code as required fields' do + action = register + expect(action.scope).to eq(ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope::SINGLE) + expect(action.form.map { |f| f[:label] }).to eq(['Holder name', 'Account number', 'Bank code']) + expect(action.form.all? { |f| f[:is_required] }).to be(true) + end + + it 'POSTs to /external_accounts with the form values' do + action = register + allow(client).to receive(:create_external_account).and_return({ 'id' => 'ea_9' }) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(form_values: { + 'Holder name' => 'Acme', + 'Account number' => 'FR76123', + 'Bank code' => 'BNPAFRPP' + }) + + result = action.execute.call(context, result_builder) + + expect(client).to have_received(:create_external_account).with( + 'holder_name' => 'Acme', 'account_number' => 'FR76123', 'bank_code' => 'BNPAFRPP' + ) + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('External account #ea_9 created') + end + + it 'raises ArgumentError without :datasource' do + expect { described_class.new.run(nil, collection_customizer, {}) } + .to raise_error(ArgumentError, /datasource/) + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account_spec.rb new file mode 100644 index 000000000..cd192f66c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::CreateInternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, { datasource: datasource }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + it 'registers a SINGLE action with type/name/holder_name/account_number as required fields' do + action = register + expect(action.form.map { |f| f[:label] }).to eq(['Type', 'Name', 'Holder name', 'Account number']) + expect(action.form.all? { |f| f[:is_required] }).to be(true) + end + + it 'restricts Type to the documented enum values' do + action = register + type_field = action.form.find { |f| f[:label] == 'Type' } + expect(type_field[:enum_values]).to eq(%w[own virtual]) + end + + it 'POSTs to /internal_accounts with the four required fields' do + action = register + allow(client).to receive(:create_internal_account).and_return({ 'id' => 'ia_1' }) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(form_values: { + 'Type' => 'own', + 'Name' => 'Main', + 'Holder name' => 'Acme SAS', + 'Account number' => 'FR76123' + }) + + action.execute.call(context, result_builder) + + expect(client).to have_received(:create_internal_account).with( + 'type' => 'own', 'name' => 'Main', 'holder_name' => 'Acme SAS', 'account_number' => 'FR76123' + ) + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order_spec.rb new file mode 100644 index 000000000..7f7eab783 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order_spec.rb @@ -0,0 +1,70 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::CreatePaymentOrder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, { datasource: datasource }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + it 'registers a SINGLE action with the six required Numeral fields' do + action = register + labels = action.form.map { |f| f[:label] } + expect(labels).to eq(['Type', 'Direction', 'Amount', 'Currency', 'Reference', 'Connected account id']) + expect(action.form.all? { |f| f[:is_required] }).to be(true) + end + + it 'restricts Direction to credit/debit and uses Number for Amount' do + action = register + direction = action.form.find { |f| f[:label] == 'Direction' } + amount = action.form.find { |f| f[:label] == 'Amount' } + expect(direction[:enum_values]).to eq(%w[credit debit]) + expect(amount[:type]).to eq(ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType::NUMBER) + end + + it 'POSTs to /payment_orders with the parsed integer amount' do + action = register + allow(client).to receive(:create_payment_order).and_return({ 'id' => 'po_1' }) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(form_values: { + 'Type' => 'sepa_credit_transfer', + 'Direction' => 'credit', + 'Amount' => '1500', + 'Currency' => 'EUR', + 'Reference' => 'INV-42', + 'Connected account id' => 'ca_1' + }) + + result = action.execute.call(context, result_builder) + + expect(client).to have_received(:create_payment_order).with(hash_including( + 'type' => 'sepa_credit_transfer', + 'direction' => 'credit', + 'amount' => 1500, + 'currency' => 'EUR', + 'reference' => 'INV-42', + 'connected_account_id' => 'ca_1' + )) + expect(result[:message]).to include('Payment order #po_1 created') + end + + it 'rejects a non-integer Amount' do + action = register + allow(client).to receive(:create_payment_order) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new(form_values: { + 'Type' => 'sepa_credit_transfer', + 'Direction' => 'credit', + 'Amount' => 'abc', + 'Currency' => 'EUR', + 'Reference' => 'r', + 'Connected account id' => 'ca_1' + }) + + result = action.execute.call(context, result_builder) + + expect(client).not_to have_received(:create_payment_order) + expect(result[:type]).to eq('Error') + expect(result[:message]).to include('Amount') + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification_spec.rb new file mode 100644 index 000000000..3c6187415 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification_spec.rb @@ -0,0 +1,87 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::TriggerPayeeVerification do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_external_account_id' } + let(:action_scope) { ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered + end + + describe '#run' do + it 'registers both single and bulk by default' do + register + expect(collection_customizer.registered.keys).to contain_exactly( + 'Trigger payee verification', 'Trigger payee verification on selected accounts' + ) + end + + it 'honors :scopes to filter variants' do + register(scopes: %i[bulk]) + expect(collection_customizer.registered.keys).to contain_exactly( + 'Trigger payee verification on selected accounts' + ) + end + + it 'binds the right ActionScope to each variant' do + register + single = collection_customizer.registered['Trigger payee verification'] + bulk = collection_customizer.registered['Trigger payee verification on selected accounts'] + expect(single.scope).to eq(action_scope::SINGLE) + expect(bulk.scope).to eq(action_scope::BULK) + end + + it 'raises ArgumentError without :record_id_field' do + expect { described_class.new.run(nil, collection_customizer, datasource: datasource) } + .to raise_error(ArgumentError, /record_id_field/) + end + end + + describe 'executor' do + let(:single) { register[collection_customizer.registered.keys.first] } + let(:bulk) { register[collection_customizer.registered.keys.last] } + + it 'POSTs /external_accounts/{id}/verify for each id' do + allow(client).to receive(:verify_external_account) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: %w[a b].map { |id| { record_id_field => id } } + ) + + result = bulk.execute.call(context, result_builder) + + %w[a b].each { |id| expect(client).to have_received(:verify_external_account).with(id) } + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('2 external accounts now pending verification') + end + + it 'returns an Error when no id is found on the host record' do + allow(client).to receive(:verify_external_account) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => nil }] + ) + + result = single.execute.call(context, result_builder) + + expect(result[:type]).to eq('Error') + expect(result[:message]).to include(record_id_field) + end + + it 'surfaces partial success on bulk: continues past per-id failures' do + allow(client).to receive(:verify_external_account).with('a') + allow(client).to receive(:verify_external_account).with('b').and_raise(StandardError, 'boom') + allow(ForestAdminDatasourceMambuPayments.logger).to receive(:warn) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: %w[a b].map { |id| { record_id_field => id } } + ) + + result = bulk.execute.call(context, result_builder) + + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('External account #a', 'now pending verification', '1 failed: b') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder_spec.rb new file mode 100644 index 000000000..17dfd8077 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder_spec.rb @@ -0,0 +1,78 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::UpdateAccountHolder do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_account_holder_id' } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + describe '#run' do + it 'registers a SINGLE-scoped action with the documented form fields' do + action = register + + expect(collection_customizer.registered.keys).to contain_exactly(described_class::NAME) + expect(action.scope).to eq(ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope::SINGLE) + expect(action.form.map { |f| f[:label] }).to eq(['Name']) + expect(action.form.first[:is_required]).to be_falsey + end + + it 'raises ArgumentError without :datasource' do + expect { described_class.new.run(nil, collection_customizer, record_id_field: 'x') } + .to raise_error(ArgumentError, /datasource/) + end + + it 'raises ArgumentError without :record_id_field' do + expect { described_class.new.run(nil, collection_customizer, datasource: datasource) } + .to raise_error(ArgumentError, /record_id_field/) + end + end + + describe 'executor' do + it 'PATCHes /account_holders/{id} with the changed Name' do + action = register + allow(client).to receive(:update_account_holder) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'ah_1' }], form_values: { 'Name' => 'New name' } + ) + + result = action.execute.call(context, result_builder) + + expect(client).to have_received(:update_account_holder).with('ah_1', 'name' => 'New name') + expect(result[:type]).to eq('Success') + expect(result[:message]).to include('Account holder #ah_1 updated') + end + + it 'returns an error when no host record carries an id' do + action = register + allow(client).to receive(:update_account_holder) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => nil }], form_values: { 'Name' => 'x' } + ) + + result = action.execute.call(context, result_builder) + + expect(client).not_to have_received(:update_account_holder) + expect(result[:type]).to eq('Error') + expect(result[:message]).to include(record_id_field) + end + + it 'returns an error when no field is filled in the form' do + action = register + allow(client).to receive(:update_account_holder) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'ah_1' }], form_values: { 'Name' => '' } + ) + + result = action.execute.call(context, result_builder) + + expect(client).not_to have_received(:update_account_holder) + expect(result[:type]).to eq('Error') + expect(result[:message]).to include('Nothing to update') + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account_spec.rb new file mode 100644 index 000000000..3dfc3e7ce --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account_spec.rb @@ -0,0 +1,31 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::UpdateExternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_external_account_id' } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + it 'PATCHes /external_accounts/{id} with only the filled fields' do + action = register + allow(client).to receive(:update_external_account) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'ea_1' }], + form_values: { 'Holder name' => 'New', 'Account number' => '', 'Bank code' => '' } + ) + + action.execute.call(context, result_builder) + + expect(client).to have_received(:update_external_account).with('ea_1', 'holder_name' => 'New') + end + + it 'raises ArgumentError without :record_id_field' do + expect { described_class.new.run(nil, collection_customizer, datasource: datasource) } + .to raise_error(ArgumentError, /record_id_field/) + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account_spec.rb new file mode 100644 index 000000000..8a15daf82 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account_spec.rb @@ -0,0 +1,26 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::SmartActions::UpdateInternalAccount do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceMambuPayments::Datasource, client: client) } + let(:result_builder) { ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder.new } + let(:collection_customizer) { ForestAdminDatasourceMambuPayments::PluginSupport::FakeCollection.new } + let(:record_id_field) { 'mambu_internal_account_id' } + + def register(opts = {}) + described_class.new.run(nil, collection_customizer, + { datasource: datasource, record_id_field: record_id_field }.merge(opts)) + collection_customizer.registered[opts[:action_name] || described_class::NAME] + end + + it 'PATCHes /internal_accounts/{id} with only the filled fields' do + action = register + allow(client).to receive(:update_internal_account) + context = ForestAdminDatasourceMambuPayments::PluginSupport::FakeContext.new( + records: [{ record_id_field => 'ia_1' }], + form_values: { 'Name' => 'New', 'Holder name' => '', 'Account number' => '' } + ) + + action.execute.call(context, result_builder) + + expect(client).to have_received(:update_internal_account).with('ia_1', 'name' => 'New') + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb new file mode 100644 index 000000000..09f4e7e8a --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb @@ -0,0 +1,120 @@ +module ForestAdminDatasourceMambuPayments + module PluginSupport + # Stand-in for an ActionContext: the executors only call get_records, + # get_record, form_values, and (for write-backs) collection/filter. + class FakeContext + attr_reader :form_values, :collection, :filter + + def initialize(records: [], form_values: {}, collection: nil, filter: nil) + @records = records + @form_values = form_values + @collection = collection + @filter = filter + end + + def get_records(_fields = []) + @records + end + + def get_record(_fields = []) + @records.first || {} + end + end + + # Minimal CollectionCustomizer that records #add_action calls. + class FakeCollection + attr_reader :registered + + def initialize + @registered = {} + end + + def add_action(name, action) + @registered[name] = action + end + end + + # Minimal CollectionCustomizer that records relation/import_field calls, + # used by the relation plugin specs. Mirrors the public DSL shape exposed + # by ForestAdminDatasourceCustomizer::CollectionCustomizer. + class FakeRelationCollection + attr_reader :imported_fields, :computed_fields, :many_to_one_relations, :one_to_many_relations, + :operator_handlers + + def initialize + @imported_fields = {} + @computed_fields = {} + @many_to_one_relations = {} + @one_to_many_relations = {} + @operator_handlers = {} + end + + def import_field(name, options = {}) + @imported_fields[name] = options + self + end + + def add_field(name, definition) + @computed_fields[name] = definition + self + end + + def add_many_to_one_relation(name, foreign_collection, options = {}) + @many_to_one_relations[name] = options.merge(foreign_collection: foreign_collection) + self + end + + def add_one_to_many_relation(name, foreign_collection, options = {}) + @one_to_many_relations[name] = options.merge(foreign_collection: foreign_collection) + self + end + + def replace_field_operator(name, operator, &block) + @operator_handlers[[name, operator]] = block + self + end + end + + # Stand-in for the CollectionCustomizationContext passed to + # replace_field_operator handlers. Exposes a `.datasource` whose + # `get_collection(name).list(filter, projection)` returns a preloaded + # set of records. + class FakeOperatorContext + def initialize(collections_data) + @collections_data = collections_data + end + + def datasource + self + end + + def get_collection(name) + FakeOperatorCollection.new(@collections_data[name] || []) + end + end + + class FakeOperatorCollection + def initialize(records) + @records = records + end + + def list(_filter, _projection) + @records + end + end + + # Stand-in for a DatasourceCustomizer: records which collection was + # customized and yields the matching FakeRelationCollection. + class FakeDatasourceCustomizer + attr_reader :collections + + def initialize + @collections = Hash.new { |h, k| h[k] = FakeRelationCollection.new } + end + + def customize_collection(name) + yield(@collections[name]) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/query/condition_tree_translator_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/query/condition_tree_translator_spec.rb new file mode 100644 index 000000000..9177ed1e6 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/query/condition_tree_translator_spec.rb @@ -0,0 +1,96 @@ +module ForestAdminDatasourceMambuPayments + RSpec.describe Query::ConditionTreeTranslator do + let(:leaf_klass) { ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf } + let(:branch_klass) { ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch } + + let(:filters) do + { + 'connected_account_id' => { ops: %w[equal in] }, + 'currency' => { ops: %w[equal], param: 'currency_code' } + } + end + + def translate(node) + described_class.call(node, api_filters: filters) + end + + describe 'happy paths' do + it 'returns {} for a nil condition tree' do + expect(described_class.call(nil, api_filters: filters)).to eq({}) + end + + it 'translates an EQUAL leaf to a scalar query param' do + leaf = leaf_klass.new('connected_account_id', 'equal', 'acc1') + expect(translate(leaf)).to eq('connected_account_id' => 'acc1') + end + + it 'translates an IN leaf to an Array (client joins with commas)' do + leaf = leaf_klass.new('connected_account_id', 'in', %w[a b]) + expect(translate(leaf)).to eq('connected_account_id' => %w[a b]) + end + + it 'honours the param: override' do + leaf = leaf_klass.new('currency', 'equal', 'EUR') + expect(translate(leaf)).to eq('currency_code' => 'EUR') + end + + it 'merges children of a top-level AND branch' do + branch = branch_klass.new('And', [ + leaf_klass.new('connected_account_id', 'equal', 'acc1'), + leaf_klass.new('currency', 'equal', 'EUR') + ]) + expect(translate(branch)).to eq('connected_account_id' => 'acc1', 'currency_code' => 'EUR') + end + end + + describe 'unsupported predicates' do + it 'raises on a leaf field that is not in api_filters' do + leaf = leaf_klass.new('status', 'equal', 'pending') + expect { translate(leaf) } + .to raise_error(UnsupportedOperatorError, /does not yet translate filters on 'status'/) + end + + it 'raises on a declared field with an undeclared operator' do + leaf = leaf_klass.new('currency', 'not_equal', 'EUR') + expect { translate(leaf) } + .to raise_error(UnsupportedOperatorError, /not supported on field 'currency'/) + end + + it 'raises on a top-level OR aggregator' do + branch = branch_klass.new('Or', [ + leaf_klass.new('connected_account_id', 'equal', 'a'), + leaf_klass.new('connected_account_id', 'equal', 'b') + ]) + expect { translate(branch) } + .to raise_error(UnsupportedOperatorError, /do not support OR aggregation/) + end + + it 'raises when two children map to the same query param' do + branch = branch_klass.new('And', [ + leaf_klass.new('connected_account_id', 'equal', 'a'), + leaf_klass.new('connected_account_id', 'in', %w[b c]) + ]) + expect { translate(branch) } + .to raise_error(UnsupportedOperatorError, /Conflicting predicates on 'connected_account_id'/) + end + + it 'raises on EQUAL with a nil value (use PRESENT / BLANK instead)' do + leaf = leaf_klass.new('connected_account_id', 'equal', nil) + expect { translate(leaf) } + .to raise_error(UnsupportedOperatorError, /value on 'connected_account_id' is nil/) + end + + it 'raises on IN with an empty array (would silently match everything)' do + leaf = leaf_klass.new('connected_account_id', 'in', []) + expect { translate(leaf) } + .to raise_error(UnsupportedOperatorError, /empty array/) + end + + it 'raises with empty api_filters: any non-id predicate fails loud' do + leaf = leaf_klass.new('connected_account_id', 'equal', 'a') + expect { described_class.call(leaf, api_filters: {}) } + .to raise_error(UnsupportedOperatorError) + end + end + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb b/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb new file mode 100644 index 000000000..d9de425f2 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb @@ -0,0 +1,38 @@ +require 'simplecov' +begin + require 'simplecov_json_formatter' + require 'simplecov-html' + SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] +rescue LoadError + # Local Gemfile run without the CI formatters; default text output is fine. +end + +SimpleCov.start do + add_filter '/spec/' + enable_coverage :branch + minimum_coverage 90 +end + +SimpleCov.coverage_dir 'coverage' + +require 'webmock/rspec' +require 'forest_admin_datasource_customizer' +require 'forest_admin_datasource_mambu_payments' +require_relative 'forest_admin_datasource_mambu_payments/plugins/support' + +WebMock.disable_net_connect!(allow_localhost: true) + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |m| + m.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.warnings = false + config.order = :random + Kernel.srand config.seed + + config.before { WebMock.reset! } +end