From e54c558107c80ac88253944ccbd1f97e1b1557d6 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 11 May 2026 15:23:54 +0200 Subject: [PATCH 01/24] feat(mambu_payments): add datasource with 7 collections Scaffold a new forest_admin_datasource_mambu_payments gem wrapping the Numeral API (rebranded Mambu Payments). Surfaces connected_accounts, payment_orders, transactions, balances, account_holders, external_accounts and internal_accounts as Forest collections with the relations between them. Authentication uses the x-api-key header. Full CRUD where the API supports it; transactions and balances are read-only. Manual ManyToOne embedding inside each list() because the customizer's RelationCollectionDecorator only resolves emulated relations. RSpec suite covers configuration, client (WebMock), datasource, base helpers and every collection at 98% line / 90% branch coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.rspec | 3 + .../Gemfile | 15 + .../Rakefile | 6 + ...st_admin_datasource_mambu_payments.gemspec | 37 +++ .../forest_admin_datasource_mambu_payments.rb | 32 ++ .../client.rb | 112 +++++++ .../client/reads.rb | 26 ++ .../client/writes.rb | 25 ++ .../collections/account_holder.rb | 92 ++++++ .../collections/balance.rb | 97 +++++++ .../collections/base_collection.rb | 96 ++++++ .../collections/connected_account.rb | 157 ++++++++++ .../collections/external_account.rb | 160 ++++++++++ .../collections/internal_account.rb | 177 ++++++++++++ .../collections/payment_order.rb | 154 ++++++++++ .../collections/transaction.rb | 157 ++++++++++ .../configuration.rb | 36 +++ .../datasource.rb | 25 ++ .../version.rb | 3 + .../client_spec.rb | 273 ++++++++++++++++++ .../collections/account_holder_spec.rb | 130 +++++++++ .../collections/balance_spec.rb | 96 ++++++ .../collections/base_collection_spec.rb | 188 ++++++++++++ .../collections/connected_account_spec.rb | 140 +++++++++ .../collections/external_account_spec.rb | 116 ++++++++ .../collections/internal_account_spec.rb | 116 ++++++++ .../collections/payment_order_spec.rb | 145 ++++++++++ .../collections/transaction_spec.rb | 115 ++++++++ .../configuration_spec.rb | 53 ++++ .../datasource_spec.rb | 28 ++ .../spec/spec_helper.rb | 36 +++ 31 files changed, 2846 insertions(+) create mode 100644 packages/forest_admin_datasource_mambu_payments/.rspec create mode 100644 packages/forest_admin_datasource_mambu_payments/Gemfile create mode 100644 packages/forest_admin_datasource_mambu_payments/Rakefile create mode 100644 packages/forest_admin_datasource_mambu_payments/forest_admin_datasource_mambu_payments.gemspec create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/reads.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/writes.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/balance.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/configuration.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/client_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/account_holder_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/balance_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/base_collection_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/connected_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/external_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/internal_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_order_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/transaction_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/configuration_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/spec_helper.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..56cf384f3 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' + +gemspec + +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/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..01fc92d9c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments.rb @@ -0,0 +1,32 @@ +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 + class APIError < Error; 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..af652b4ed --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client.rb @@ -0,0 +1,112 @@ +module ForestAdminDatasourceMambuPayments + 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 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 + + # 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'] + return wrapped if wrapped.is_a?(Array) + + fallback = body.values.find { |v| v.is_a?(Array) } + if fallback + ForestAdminDatasourceMambuPayments.logger.warn( + "[forest_admin_datasource_mambu_payments] list(#{path}) used wrapper-key fallback; " \ + "body keys=#{body.keys.inspect}" + ) + return fallback + end + + [] + 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 StandardError => e + raise APIError, "Mambu Payments API call failed: #{operation}: #{e.class}: #{e.message}" + 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 +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..241f9db10 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/reads.rb @@ -0,0 +1,26 @@ +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) + 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..23a187402 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/client/writes.rb @@ -0,0 +1,25 @@ +module ForestAdminDatasourceMambuPayments + class Client + module Writes + def create_connected_account(attrs) = post_resource('connected_accounts', attrs) + def update_connected_account(id, attrs) = patch_resource('connected_accounts', id, attrs) + def delete_connected_account(id) = delete_resource('connected_accounts', id) + + 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 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 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) + 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..0da7b23c5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb @@ -0,0 +1,92 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class AccountHolder < BaseCollection + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + def initialize(datasource) + super(datasource, 'MambuAccountHolder') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + records.map { |r| project(serialize(r), projection) } + 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 + + protected + + def aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_account_holder(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_account_holders(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object created_at disabled_at].each { |k| attrs.delete(k) } + attrs + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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..30bd6c8af --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/balance.rb @@ -0,0 +1,97 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class Balance < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + def initialize(datasource) + super(datasource, 'MambuBalance') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_balance(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_balances(page: page, limit: per_page) + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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..7d2d45344 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb @@ -0,0 +1,96 @@ +module ForestAdminDatasourceMambuPayments + module Collections + class BaseCollection < ForestAdminDatasourceToolkit::Collection + ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + 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 + + def aggregate(caller, filter, aggregation, _limit = nil) + unless aggregation.operation == 'Count' && aggregation.field.nil? && aggregation.groups.empty? + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'Mambu Payments datasource only supports Count aggregation without groups.' + end + + [{ 'value' => aggregate_count(caller, filter), 'group' => {} }] + end + + protected + + def aggregate_count(_caller, _filter) + raise NotImplementedError, "#{self.class} did not implement aggregate_count" + 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 project(record, projection) + return record if projection.nil? + + wanted = Array(projection).map(&:to_s).reject { |p| p.include?(':') } + return record if wanted.empty? + + wanted.to_h { |k| [k, record[k]] } + end + + def translate_page(page) + return [1, Client::MAX_PER_PAGE] if page.nil? + + per_page = page.limit&.positive? ? [page.limit, Client::MAX_PER_PAGE].min : Client::MAX_PER_PAGE + page_num = (page.offset.to_i / per_page) + 1 + [page_num, per_page] + end + + def ids_for(caller, filter) + 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 + + # Bulk-fetches records for a ManyToOne relation and writes the serialized + # related record back onto each row. The customizer's relation decorator + # only handles emulated relations, so native datasource relations (like + # ours) must populate the sub-record themselves. + # + # Expected opts keys: :foreign_key, :relation_name, :fetcher, :serializer. + def embed_many_to_one(rows, sources, projection, **opts) + relation_name = opts.fetch(:relation_name) + return if projection.nil? || !relations_in(projection).include?(relation_name) + + foreign_key = opts.fetch(:foreign_key) + ids = sources.filter_map { |s| s[foreign_key] }.reject { |id| id.nil? || id.to_s.empty? }.uniq + return if ids.empty? + + cache = ids.to_h { |id| [id, opts.fetch(:fetcher).call(id)] }.compact + rows.each_with_index do |row, i| + fk_value = sources[i][foreign_key] + next if fk_value.nil? || fk_value.to_s.empty? + + raw = cache[fk_value] + row[relation_name] = raw && opts.fetch(:serializer).call(raw) + end + 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..22c4dd486 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb @@ -0,0 +1,157 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class ConnectedAccount < BaseCollection + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + def initialize(datasource) + super(datasource, 'MambuConnectedAccount') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + records.map { |r| project(serialize(r), projection) } + end + + def create(_caller, data) + serialize(datasource.client.create_connected_account(build_payload(data))) + end + + def update(caller, filter, patch) + payload = build_payload(patch) + ids_for(caller, filter).each { |id| datasource.client.update_connected_account(id, payload) } + end + + def delete(caller, filter) + ids_for(caller, filter).each { |id| datasource.client.delete_connected_account(id) } + 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 + + protected + + def aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_connected_account(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_connected_accounts(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object created_at disabled_at bank_data].each { |k| attrs.delete(k) } + attrs + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('distinguished_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('settlement_account', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('legal_entity_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('receiving_agent', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('services_activated', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('file_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, + is_read_only: false, is_sortable: false)) + add_field('return_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, + is_read_only: false, is_sortable: false)) + add_field('incoming_instant_payment_auto_approval', + ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, + is_read_only: false, is_sortable: false)) + add_field('address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('account_number_generation_settings', + ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/ClassLength, Metrics/MethodLength 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..a2efc7fb8 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb @@ -0,0 +1,160 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class ExternalAccount < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + def initialize(datasource) + super(datasource, 'MambuExternalAccount') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_external_account(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_external_accounts(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object status status_details created_at disabled_at account_verification].each { |k| attrs.delete(k) } + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ah = datasource.get_collection('MambuAccountHolder') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'account_holder_id', relation_name: 'account_holder', + fetcher: ->(id) { datasource.client.find_account_holder(id) }, + serializer: ->(raw) { ah.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('holder_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('holder_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('account_holder_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('organization_identification', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('company_registration_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('company_registration_number_type', + ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('account_verification', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/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..1ce3bbc94 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb @@ -0,0 +1,177 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class InternalAccount < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + def initialize(datasource) + super(datasource, 'MambuInternalAccount') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_internal_account(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_internal_accounts(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object status status_details created_at bank_data].each { |k| attrs.delete(k) } + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ah = datasource.get_collection('MambuAccountHolder') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'account_holder_id', relation_name: 'account_holder', + fetcher: ->(id) { datasource.client.find_account_holder(id) }, + serializer: ->(raw) { ah.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('holder_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('alternative_holder_names', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('connected_account_ids', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('holder_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('account_holder_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('organization_identification', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('customer_bic', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('distinguished_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('currencies', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('cbs_source', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('cbs_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('cbs_account_type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('synchronized_with_bank', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, + is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/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..fcdb2cc68 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb @@ -0,0 +1,154 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class PaymentOrder < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + def initialize(datasource) + super(datasource, 'MambuPaymentOrder') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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'], + '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 + + def aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_payment_order(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_payment_orders(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id status created_at value_date initiated_at reconciliation_status reconciled_amount].each do |k| + attrs.delete(k) + end + attrs + end + + def embed_relations(rows, records, projection) + ca = datasource.get_collection('MambuConnectedAccount') + sources = records.map { |r| attrs_of(r) } + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, is_read_only: false, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('purpose', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('requested_execution_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('initiated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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..046ad3db2 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb @@ -0,0 +1,157 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class Transaction < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + def initialize(datasource) + super(datasource, 'MambuTransaction') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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_snapshot' => a['internal_account'], + 'external_account_snapshot' => a['external_account'], + '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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_transaction(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_transactions(page: page, limit: per_page) + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + ia = datasource.get_collection('MambuInternalAccount') + ea = datasource.get_collection('MambuExternalAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'internal_account_id', relation_name: 'internal_account', + fetcher: ->(id) { datasource.client.find_internal_account(id) }, + serializer: ->(raw) { ia.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'external_account_id', relation_name: 'external_account', + fetcher: ->(id) { datasource.client.find_external_account(id) }, + serializer: ->(raw) { ea.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('category', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('structured_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('internal_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('external_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('uetr', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/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..aea1d31f5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb @@ -0,0 +1,25 @@ +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)) + 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..f1eb2cde3 --- /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'.freeze +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..d565e75ce --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/client_spec.rb @@ -0,0 +1,273 @@ +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 '#create_connected_account' do + it 'POSTs the payload as JSON and returns the response body' do + stub_request(:post, "#{base}/connected_accounts") + .with(body: { 'name' => 'Acme' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + .to_return(json('id' => 'new', 'name' => 'Acme')) + + expect(client.create_connected_account('name' => 'Acme')).to include('id' => 'new') + end + end + + describe '#update_connected_account' do + it 'PATCHes the payload to /resource/:id' do + stub_request(:patch, "#{base}/connected_accounts/abc") + .with(body: { 'name' => 'NewName' }.to_json) + .to_return(json('id' => 'abc', 'name' => 'NewName')) + + expect(client.update_connected_account('abc', 'name' => 'NewName')) + .to include('name' => 'NewName') + end + end + + describe '#delete_connected_account' do + it 'DELETEs /resource/:id and returns true on success' do + stub_request(:delete, "#{base}/connected_accounts/abc").to_return(status: 204, body: '') + expect(client.delete_connected_account('abc')).to be(true) + 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 +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..8e5bc4546 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/account_holder_spec.rb @@ -0,0 +1,130 @@ +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(page: 1, limit: Client::MAX_PER_PAGE) + end + end + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_account_holders).and_return([holder, holder]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + 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..351883adc --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/balance_spec.rb @@ -0,0 +1,96 @@ +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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_balances).and_return([balance]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(1) + 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..2e45b2440 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/base_collection_spec.rb @@ -0,0 +1,188 @@ +# rubocop:disable Metrics/ModuleLength +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 the full record when projection has only relation prefixes' do + expect(collection.send(:project, record, ['connected_account:name'])).to eq(record) + end + end + + describe '#translate_page' do + it 'defaults to page 1 / MAX_PER_PAGE when no page is given' do + expect(collection.send(:translate_page, nil)) + .to eq([1, ForestAdminDatasourceMambuPayments::Client::MAX_PER_PAGE]) + end + + it 'computes page number from offset / limit' do + page = ForestAdminDatasourceToolkit::Components::Query::Page.new(limit: 10, offset: 20) + expect(collection.send(:translate_page, page)).to eq([3, 10]) + end + + it 'caps the limit at MAX_PER_PAGE' do + page = ForestAdminDatasourceToolkit::Components::Query::Page.new(limit: 999, offset: 0) + _, per_page = collection.send(:translate_page, page) + expect(per_page).to eq(ForestAdminDatasourceMambuPayments::Client::MAX_PER_PAGE) + 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(:fetcher) { instance_double(Proc) } + let(:serializer) { ->(raw) { { 'id' => raw['id'], 'name' => raw['name'] } } } + + it 'fetches each unique FK once and assigns the serialized record' do + allow(fetcher).to receive(:call).with('a').and_return('id' => 'a', 'name' => 'Acme') + + collection.send(:embed_many_to_one, rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: fetcher, serializer: serializer) + + 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(fetcher).to have_received(:call).with('a').once + end + + it 'does nothing when the projection does not request the relation' do + allow(fetcher).to receive(:call) + + collection.send(:embed_many_to_one, rows, sources, ['id'], + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: fetcher, serializer: serializer) + + expect(rows).to all(satisfy { |r| !r.key?('connected_account') }) + expect(fetcher).not_to have_received(:call) + end + + it 'does nothing when projection is nil' do + allow(fetcher).to receive(:call) + collection.send(:embed_many_to_one, rows, sources, nil, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: fetcher, serializer: serializer) + expect(fetcher).not_to have_received(:call) + end + + it 'does nothing when no source has a usable FK' do + allow(fetcher).to receive(:call) + + empty_sources = [{ 'connected_account_id' => nil }, { 'connected_account_id' => '' }] + collection.send(:embed_many_to_one, [{ 'id' => 'a' }, { 'id' => 'b' }], empty_sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: fetcher, serializer: serializer) + + expect(fetcher).not_to have_received(:call) + end + + it 'drops rows whose fetcher returned nil (record not found)' do + allow(fetcher).to receive(:call).with('a').and_return(nil) + + collection.send(:embed_many_to_one, rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: fetcher, serializer: serializer) + + expect(rows[0]['connected_account']).to be_nil + expect(rows[1]['connected_account']).to be_nil + end + end + + describe '#aggregate' do + let(:filter) { ForestAdminDatasourceToolkit::Components::Query::Filter.new } + + it 'raises on non-Count aggregations' do + agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Sum', field: 'amount') + expect { collection.aggregate(nil, filter, agg) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /Count/) + end + + it 'raises on Count with groups' do + agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count', + groups: [{ field: 'id' }]) + expect { collection.aggregate(nil, filter, agg) } + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end + end + end +end + +# rubocop:enable Metrics/ModuleLength 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..2efdd50f4 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/connected_account_spec.rb @@ -0,0 +1,140 @@ +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 id, disabled_at, created_at as read-only' do + f = collection.schema[:fields] + %w[id disabled_at created_at].each { |k| expect(f[k].is_read_only).to be(true) } + 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(page: 1, 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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_connected_accounts).and_return([account, account]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + end + + describe '#create' do + it 'POSTs the payload stripping system-managed fields' do + allow(client).to receive(:create_connected_account) do |payload| + expect(payload).to include('name' => 'New') + expect(payload.keys).not_to include('id', 'object', 'created_at', 'disabled_at', 'bank_data') + { 'id' => 'new', 'name' => 'New' } + end + + result = collection.create(nil, + 'id' => 'ignored', 'object' => 'connected_account', + 'created_at' => 't', 'disabled_at' => 't', + 'bank_data' => {}, 'name' => 'New') + expect(result['id']).to eq('new') + end + end + + describe '#update' do + it 'PATCHes every id resolved by the filter' do + # ids_for goes through fetch_records, which calls find_* per id. + allow(client).to receive(:find_connected_account).with('a').and_return('id' => 'a') + allow(client).to receive(:find_connected_account).with('b').and_return('id' => 'b') + allow(client).to receive(:update_connected_account) + + collection.update(nil, + Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), + 'name' => 'Renamed') + + expect(client).to have_received(:update_connected_account).with('a', hash_including('name' => 'Renamed')) + expect(client).to have_received(:update_connected_account).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_connected_account).with('a').and_return('id' => 'a') + allow(client).to receive(:delete_connected_account) + + collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) + + expect(client).to have_received(:delete_connected_account).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/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/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..659b56a77 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_order_spec.rb @@ -0,0 +1,145 @@ +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(:collection) { described_class.new(datasource) } + + let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } + let(:payment_order) do + { + 'id' => 'po1', 'object' => 'payment_order', + 'connected_account_id' => 'acc1', + '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) + 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', '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 '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 + 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') + { '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) + + 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/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..0767d67e7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/transaction_spec.rb @@ -0,0 +1,115 @@ +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_snapshot', 'external_account_snapshot', + '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 '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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_transactions).and_return([transaction, transaction]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + 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..f16e392c3 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb @@ -0,0 +1,28 @@ +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' + ) + 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..18da35d47 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb @@ -0,0 +1,36 @@ +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_mambu_payments' + +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 From 8bc7cf2c3d4e526f52928545b210d544843a2012 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 11 May 2026 18:47:02 +0200 Subject: [PATCH 02/24] feat(mambu_payments): add incoming_payments, direct_debit_mandates and expected_payments collections Surface three additional Numeral resources as Forest collections. IncomingPayment is read-only (bank-rail events, mirrors Transaction); DirectDebitMandate and ExpectedPayment expose full CRUD (user-initiated, mirror PaymentOrder). Each collection embeds its ManyToOne relations (connected_account, internal_account, external_account) inline in list() since RelationCollectionDecorator only resolves emulated relations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .rubocop.yml | 1 + .../client/reads.rb | 9 + .../client/writes.rb | 8 + .../collections/direct_debit_mandate.rb | 163 ++++++++++++++++ .../collections/expected_payment.rb | 183 ++++++++++++++++++ .../collections/incoming_payment.rb | 161 +++++++++++++++ .../datasource.rb | 3 + .../client_spec.rb | 69 +++++++ .../collections/base_collection_spec.rb | 3 - .../collections/direct_debit_mandate_spec.rb | 166 ++++++++++++++++ .../collections/expected_payment_spec.rb | 160 +++++++++++++++ .../collections/incoming_payment_spec.rb | 140 ++++++++++++++ .../datasource_spec.rb | 3 +- 13 files changed, 1065 insertions(+), 4 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/expected_payment_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/incoming_payment_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index dcb1f6894..0b9a1fe57 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -282,6 +282,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/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 index 241f9db10..5e2102881 100644 --- 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 @@ -21,6 +21,15 @@ 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) 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 index 23a187402..50a69db08 100644 --- 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 @@ -20,6 +20,14 @@ def delete_external_account(id) = delete_resource('external_accounts', id 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) end end end 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..fb63d3804 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb @@ -0,0 +1,163 @@ +# 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 + + def initialize(datasource) + super(datasource, 'MambuDirectDebitMandate') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_direct_debit_mandate(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_direct_debit_mandates(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object status created_at].each { |k| attrs.delete(k) } + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + ea = datasource.get_collection('MambuExternalAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'external_account_id', relation_name: 'external_account', + fetcher: ->(id) { datasource.client.find_external_account(id) }, + serializer: ->(raw) { ea.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('scheme', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_SCHEME, is_read_only: false, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('sequence_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_SEQUENCE_TYPE, + is_read_only: false, is_sortable: true)) + add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('unique_mandate_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('signature_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('signature_location', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('creditor', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('debtor', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('debtor_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('amendment_information', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/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..02416d7b7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb @@ -0,0 +1,183 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class ExpectedPayment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + ENUM_DIRECTION = %w[debit credit].freeze + + def initialize(datasource) + super(datasource, 'MambuExpectedPayment') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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'], + 'connected_account_id' => a['connected_account_id'], + 'internal_account_id' => a['internal_account_id'], + 'external_account_id' => a['external_account_id'], + 'type' => a['type'], + 'direction' => a['direction'], + 'status' => a['status'], + 'amount' => a['amount'], + 'amount_min' => a['amount_min'], + 'amount_max' => a['amount_max'], + 'currency' => a['currency'], + 'reference' => a['reference'], + 'end_to_end_id' => a['end_to_end_id'], + 'expected_at' => a['expected_at'], + 'earliest_expected_at' => a['earliest_expected_at'], + 'latest_expected_at' => a['latest_expected_at'], + 'counterparty' => a['counterparty'], + 'matched_amount' => a['matched_amount'], + 'matched_payments' => a['matched_payments'], + 'custom_fields' => a['custom_fields'], + 'metadata' => a['metadata'], + 'created_at' => a['created_at'] + } + end + + protected + + def aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_expected_payment(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_expected_payments(page: page, limit: per_page) + end + + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object status created_at matched_amount matched_payments].each { |k| attrs.delete(k) } + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + ia = datasource.get_collection('MambuInternalAccount') + ea = datasource.get_collection('MambuExternalAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'internal_account_id', relation_name: 'internal_account', + fetcher: ->(id) { datasource.client.find_internal_account(id) }, + serializer: ->(raw) { ia.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'external_account_id', relation_name: 'external_account', + fetcher: ->(id) { datasource.client.find_external_account(id) }, + serializer: ->(raw) { ea.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, + is_read_only: false, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('amount_min', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('amount_max', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('earliest_expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('latest_expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('counterparty', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('matched_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('matched_payments', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/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..c5ca0f4cb --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb @@ -0,0 +1,161 @@ +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +module ForestAdminDatasourceMambuPayments + module Collections + class IncomingPayment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + def initialize(datasource) + super(datasource, 'MambuIncomingPayment') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_incoming_payment(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_incoming_payments(page: page, limit: per_page) + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + ia = datasource.get_collection('MambuInternalAccount') + ea = datasource.get_collection('MambuExternalAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'internal_account_id', relation_name: 'internal_account', + fetcher: ->(id) { datasource.client.find_internal_account(id) }, + serializer: ->(raw) { ia.serialize(raw) } + ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'external_account_id', relation_name: 'external_account', + fetcher: ->(id) { datasource.client.find_external_account(id) }, + serializer: ->(raw) { ea.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('uetr', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('structured_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('return_information', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb index aea1d31f5..50ae8b18d 100644 --- 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 @@ -20,6 +20,9 @@ def register_collections 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)) end end 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 index d565e75ce..f30638198 100644 --- 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 @@ -270,4 +270,73 @@ def json(payload, status = 200) 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 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 index 2e45b2440..cf0e9c140 100644 --- 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 @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ModuleLength module ForestAdminDatasourceMambuPayments Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch @@ -184,5 +183,3 @@ module ForestAdminDatasourceMambuPayments end end end - -# rubocop:enable Metrics/ModuleLength 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/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..ec6f0c8a9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/expected_payment_spec.rb @@ -0,0 +1,160 @@ +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' => 'ep1', 'object' => 'expected_payment', + 'connected_account_id' => 'acc1', + 'internal_account_id' => 'ia1', 'external_account_id' => 'ea1', + 'type' => 'sepa_credit_transfer', 'direction' => 'credit', + 'status' => 'pending', + 'amount' => 10_000, 'amount_min' => nil, 'amount_max' => nil, + 'currency' => 'EUR', + 'reference' => 'INV-42', 'end_to_end_id' => 'e2e', + 'expected_at' => '2026-06-01', + 'earliest_expected_at' => '2026-05-28', 'latest_expected_at' => '2026-06-05', + 'counterparty' => { 'name' => 'Jane Doe' }, + 'matched_amount' => 0, 'matched_payments' => [], + 'custom_fields' => {}, 'metadata' => {}, + 'created_at' => '2026-05-11T08:50:28Z' + } + 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', 'internal_account_id', 'external_account_id', + 'type', 'direction', 'status', 'amount', 'amount_min', 'amount_max', 'currency', + 'reference', 'end_to_end_id', 'expected_at', + 'earliest_expected_at', 'latest_expected_at', + 'counterparty', 'matched_amount', 'matched_payments', + '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 '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 'marks reconciliation outcome and system-managed fields as read-only' do + f = collection.schema[:fields] + %w[id status matched_amount matched_payments created_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 '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) + + rows = collection.list(nil, Filter.new, ['id', 'amount']) + + expect(rows).to eq([{ 'id' => 'ep1', 'amount' => 10_000 }]) + expect(client).not_to have_received(:find_connected_account) + end + + it 'embeds connected_account, internal_account and external_account when requested' do + allow(client).to receive(:list_expected_payments).and_return([expected_payment]) + allow(client).to receive(:find_connected_account).with('acc1').and_return(account) + 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', 'connected_account:name', 'internal_account:id', 'external_account:id']) + + expect(rows.first['connected_account']).to include('name' => 'Acme') + expect(rows.first['internal_account']).to include('id' => 'ia1') + expect(rows.first['external_account']).to include('id' => 'ea1') + end + + it 'short-circuits to find_expected_payment on id lookup' do + allow(client).to receive(:find_expected_payment).with('ep1').and_return(expected_payment) + allow(client).to receive(:list_expected_payments) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'ep1')) + collection.list(nil, filter, nil) + + expect(client).to have_received(:find_expected_payment).with('ep1') + 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' => 10_000, 'direction' => 'credit') + expect(payload.keys).not_to include('id', 'object', 'status', 'created_at', + 'matched_amount', 'matched_payments') + { 'id' => 'ep1', 'amount' => 10_000 } + end + + collection.create(nil, + 'id' => 'ignored', 'object' => 'expected_payment', + 'status' => 'pending', 'created_at' => 't', + 'matched_amount' => 0, 'matched_payments' => [], + 'amount' => 10_000, 'direction' => 'credit') + + 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' => 200) + + expect(client).to have_received(:update_expected_payment).with('a', hash_including('amount' => 200)) + expect(client).to have_received(:update_expected_payment).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_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/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..817961d69 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/incoming_payment_spec.rb @@ -0,0 +1,140 @@ +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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_incoming_payments).and_return([incoming_payment, incoming_payment]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + 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 index f16e392c3..813f1612b 100644 --- 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 @@ -22,7 +22,8 @@ ds = described_class.new(**valid_args) expect(ds.collections.keys).to contain_exactly( 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', - 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount' + 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', + 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment' ) end end From 955f6ee241d6affa26e74bf2936bb885e263d3a3 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 12 May 2026 19:52:45 +0200 Subject: [PATCH 03/24] fix(mambu_payments): align expected_payment schema with Numeral payload Realign MambuExpectedPayment fields with a real /v1/expected_payments response: rename amount_min/max to amount_from/to, expected_at family to start_date/end_date, drop status/type/reference/end_to_end_id/counterparty/ matched_*, add idempotency_key, descriptions, updated_at and canceled_at, and expose internal_account/external_account as read-only Json snapshots alongside the existing ManyToOne relations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/expected_payment.rb | 78 +++++----- .../collections/expected_payment_spec.rb | 144 ++++++++++++------ 2 files changed, 136 insertions(+), 86 deletions(-) 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 index 02416d7b7..bd99eeeb7 100644 --- 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 @@ -38,27 +38,26 @@ def serialize(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'], - 'type' => a['type'], 'direction' => a['direction'], - 'status' => a['status'], - 'amount' => a['amount'], - 'amount_min' => a['amount_min'], - 'amount_max' => a['amount_max'], + 'amount_from' => a['amount_from'], + 'amount_to' => a['amount_to'], 'currency' => a['currency'], - 'reference' => a['reference'], - 'end_to_end_id' => a['end_to_end_id'], - 'expected_at' => a['expected_at'], - 'earliest_expected_at' => a['earliest_expected_at'], - 'latest_expected_at' => a['latest_expected_at'], - 'counterparty' => a['counterparty'], - 'matched_amount' => a['matched_amount'], - 'matched_payments' => a['matched_payments'], + 'start_date' => a['start_date'], + 'end_date' => a['end_date'], + 'descriptions' => a['descriptions'], + 'internal_account_snapshot' => a['internal_account'], + 'external_account_snapshot' => a['external_account'], + 'reconciliation_status' => a['reconciliation_status'], + 'reconciled_amount' => a['reconciled_amount'], 'custom_fields' => a['custom_fields'], 'metadata' => a['metadata'], - 'created_at' => a['created_at'] + 'created_at' => a['created_at'], + 'updated_at' => a['updated_at'], + 'canceled_at' => a['canceled_at'] } end @@ -80,7 +79,8 @@ def fetch_records(_caller, filter) def build_payload(data) attrs = data.transform_keys(&:to_s) - %w[id object status created_at matched_amount matched_payments].each { |k| attrs.delete(k) } + %w[id object reconciliation_status reconciled_amount created_at updated_at canceled_at + internal_account_snapshot external_account_snapshot].each { |k| attrs.delete(k) } attrs end @@ -114,49 +114,47 @@ def define_schema is_primary_key: true, is_read_only: true, is_sortable: true)) add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: true, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: true)) add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: false)) add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: false)) - add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, enum_values: ENUM_DIRECTION, is_read_only: false, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('amount_min', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('amount_max', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) + add_field('amount_from', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('amount_to', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: false)) - add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('earliest_expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('latest_expected_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('counterparty', ColumnSchema.new(column_type: 'Json', filter_operators: [], + add_field('start_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('end_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: false, is_sortable: true)) + add_field('descriptions', ColumnSchema.new(column_type: 'Json', filter_operators: [], is_read_only: false, is_sortable: false)) - add_field('matched_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('matched_payments', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) + add_field('internal_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('external_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], is_read_only: false, is_sortable: false)) add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], is_read_only: false, is_sortable: false)) add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) end def define_relations 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 index ec6f0c8a9..7f4417d7d 100644 --- 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 @@ -16,20 +16,25 @@ module ForestAdminDatasourceMambuPayments let(:account) { { 'id' => 'acc1', 'name' => 'Acme' } } let(:expected_payment) do { - 'id' => 'ep1', 'object' => 'expected_payment', - 'connected_account_id' => 'acc1', - 'internal_account_id' => 'ia1', 'external_account_id' => 'ea1', - 'type' => 'sepa_credit_transfer', 'direction' => 'credit', - 'status' => 'pending', - 'amount' => 10_000, 'amount_min' => nil, 'amount_max' => nil, + 'id' => '019e17e6-bac7-7607-9d91-12147d8db4c8', + 'idempotency_key' => '', + 'object' => 'expected_payment', + 'direction' => 'debit', + 'amount_from' => 5000, 'amount_to' => 6000, 'currency' => 'EUR', - 'reference' => 'INV-42', 'end_to_end_id' => 'e2e', - 'expected_at' => '2026-06-01', - 'earliest_expected_at' => '2026-05-28', 'latest_expected_at' => '2026-06-05', - 'counterparty' => { 'name' => 'Jane Doe' }, - 'matched_amount' => 0, 'matched_payments' => [], - 'custom_fields' => {}, 'metadata' => {}, - 'created_at' => '2026-05-11T08:50:28Z' + '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 @@ -43,15 +48,26 @@ module ForestAdminDatasourceMambuPayments it 'declares the API-aligned columns' do keys = collection.schema[:fields].keys expect(keys).to include( - 'id', 'connected_account_id', 'internal_account_id', 'external_account_id', - 'type', 'direction', 'status', 'amount', 'amount_min', 'amount_max', 'currency', - 'reference', 'end_to_end_id', 'expected_at', - 'earliest_expected_at', 'latest_expected_at', - 'counterparty', 'matched_amount', 'matched_payments', - 'custom_fields', 'metadata', 'created_at' + 'id', 'object', 'idempotency_key', + 'connected_account_id', 'internal_account_id', 'external_account_id', + 'direction', 'amount_from', 'amount_to', 'currency', + 'start_date', 'end_date', 'descriptions', + 'internal_account_snapshot', 'external_account_snapshot', + '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 + %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].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) @@ -65,67 +81,103 @@ module ForestAdminDatasourceMambuPayments expect(f['direction'].enum_values).to contain_exactly('debit', 'credit') end - it 'marks reconciliation outcome and system-managed fields as read-only' do + it 'keeps account snapshots and descriptions as Json' do f = collection.schema[:fields] - %w[id status matched_amount matched_payments created_at].each do |k| + %w[internal_account_snapshot external_account_snapshot descriptions].each do |k| + expect(f[k].column_type).to eq('Json') + end + end + + it 'marks reconciliation outcome, snapshots and timestamps as read-only' do + f = collection.schema[:fields] + %w[id object reconciliation_status reconciled_amount + internal_account_snapshot external_account_snapshot + 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 exposes account snapshots' 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 + internal_account_snapshot external_account_snapshot 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'] + ) + expect(rows.first['external_account_snapshot']).to include('account_number' => 'AG454545') + expect(rows.first['internal_account_snapshot']).to include('account_number' => '43244675643525') + 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) - rows = collection.list(nil, Filter.new, ['id', 'amount']) + collection.list(nil, Filter.new, %w[id amount_from]) - expect(rows).to eq([{ 'id' => 'ep1', 'amount' => 10_000 }]) 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, internal_account and external_account when requested' do + 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('acc1').and_return(account) - 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', 'connected_account:name', 'internal_account:id', 'external_account:id']) + allow(client).to receive(:find_connected_account) + .with('456d2975-d58b-4a90-89b8-efcc3239c866').and_return(account) + rows = collection.list(nil, Filter.new, ['id', 'connected_account:name']) expect(rows.first['connected_account']).to include('name' => 'Acme') - expect(rows.first['internal_account']).to include('id' => 'ia1') - expect(rows.first['external_account']).to include('id' => 'ea1') + 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('ep1').and_return(expected_payment) + 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', 'ep1')) + 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).with('ep1') + 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 + it 'strips system-managed fields and snapshots before POSTing' do allow(client).to receive(:create_expected_payment) do |payload| - expect(payload).to include('amount' => 10_000, 'direction' => 'credit') - expect(payload.keys).not_to include('id', 'object', 'status', 'created_at', - 'matched_amount', 'matched_payments') - { 'id' => 'ep1', 'amount' => 10_000 } + 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', + 'internal_account_snapshot', 'external_account_snapshot') + { 'id' => 'ep1', 'amount_from' => 5000 } end collection.create(nil, 'id' => 'ignored', 'object' => 'expected_payment', - 'status' => 'pending', 'created_at' => 't', - 'matched_amount' => 0, 'matched_payments' => [], - 'amount' => 10_000, 'direction' => 'credit') + 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, + 'created_at' => 't', 'updated_at' => 't', 'canceled_at' => nil, + 'internal_account_snapshot' => { 'a' => 'b' }, + 'external_account_snapshot' => { 'a' => 'b' }, + 'amount_from' => 5000, 'amount_to' => 6000, 'direction' => 'debit') expect(client).to have_received(:create_expected_payment) end @@ -139,10 +191,10 @@ module ForestAdminDatasourceMambuPayments collection.update(nil, Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), - 'amount' => 200) + 'amount_to' => 7000) - expect(client).to have_received(:update_expected_payment).with('a', hash_including('amount' => 200)) - expect(client).to have_received(:update_expected_payment).with('b', hash_including('amount' => 200)) + 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 From fe5aed1870f30cd9f2c047b7c0e13a213a605932 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 18 May 2026 15:57:51 +0200 Subject: [PATCH 04/24] feat(mambu_payments): smart actions plugins for account holders, accounts and payment orders Adds opt-in plugins mirroring the Zendesk pattern (registered via collection_customizer.add_plugin) so smart actions can be attached to any host collection without coupling to Mambu's own collections. Ten actions: create/update for account holders, external accounts, internal accounts; create/approve/cancel for payment orders; trigger payee verification for external accounts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Gemfile | 1 + .../client.rb | 8 ++ .../client/writes.rb | 3 + .../plugins/approve_payment_order.rb | 54 +++++++++ .../plugins/cancel_payment_order.rb | 64 ++++++++++ .../plugins/create_account_holder.rb | 42 +++++++ .../plugins/create_external_account.rb | 52 ++++++++ .../plugins/create_internal_account.rb | 56 +++++++++ .../plugins/create_payment_order.rb | 64 ++++++++++ .../plugins/helpers.rb | 82 +++++++++++++ .../plugins/messages.rb | 30 +++++ .../plugins/trigger_payee_verification.rb | 56 +++++++++ .../plugins/update_account_holder.rb | 65 ++++++++++ .../plugins/update_external_account.rb | 73 +++++++++++ .../plugins/update_internal_account.rb | 73 +++++++++++ .../client_spec.rb | 26 ++++ .../datasource_spec.rb | 7 ++ .../plugins/approve_payment_order_spec.rb | 51 ++++++++ .../plugins/cancel_payment_order_spec.rb | 53 ++++++++ .../plugins/create_account_holder_spec.rb | 80 +++++++++++++ .../plugins/create_external_account_spec.rb | 41 +++++++ .../plugins/create_internal_account_spec.rb | 40 +++++++ .../plugins/create_payment_order_spec.rb | 70 +++++++++++ .../plugins/helpers_spec.rb | 113 ++++++++++++++++++ .../plugins/support.rb | 37 ++++++ .../trigger_payee_verification_spec.rb | 87 ++++++++++++++ .../plugins/update_account_holder_spec.rb | 78 ++++++++++++ .../plugins/update_external_account_spec.rb | 31 +++++ .../plugins/update_internal_account_spec.rb | 26 ++++ .../spec/spec_helper.rb | 2 + 30 files changed, 1465 insertions(+) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/messages.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/approve_payment_order_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_account_holder_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_external_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_internal_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_payment_order_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/helpers_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_account_holder_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_external_account_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_internal_account_spec.rb diff --git a/packages/forest_admin_datasource_mambu_payments/Gemfile b/packages/forest_admin_datasource_mambu_payments/Gemfile index 56cf384f3..c229ff1d5 100644 --- a/packages/forest_admin_datasource_mambu_payments/Gemfile +++ b/packages/forest_admin_datasource_mambu_payments/Gemfile @@ -2,6 +2,7 @@ source 'https://rubygems.org' gemspec +gem 'forest_admin_datasource_customizer' gem 'forest_admin_datasource_toolkit' gem 'rake', '~> 13.0' gem 'rubocop', '1.86.1' 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 index af652b4ed..a8a1f32e8 100644 --- 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 @@ -38,6 +38,14 @@ def patch_resource(path, id, attributes) 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 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 index 50a69db08..e5e66ba00 100644 --- 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 @@ -8,6 +8,8 @@ def delete_connected_account(id) = delete_resource('connected_accounts', 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) @@ -16,6 +18,7 @@ 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) diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb new file mode 100644 index 000000000..5fd28d4f8 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb @@ -0,0 +1,54 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb new file mode 100644 index 000000000..9fc6339f7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb @@ -0,0 +1,64 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb new file mode 100644 index 000000000..4a8b730a5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb @@ -0,0 +1,42 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb new file mode 100644 index 000000000..a5819a611 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb @@ -0,0 +1,52 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb new file mode 100644 index 000000000..f9b64d221 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb @@ -0,0 +1,56 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb new file mode 100644 index 000000000..19c141163 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb @@ -0,0 +1,64 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 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..f038d230c --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb @@ -0,0 +1,82 @@ +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 + + 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/trigger_payee_verification.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb new file mode 100644 index 000000000..ca187580b --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb @@ -0,0 +1,56 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb new file mode 100644 index 000000000..08c908bae --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb @@ -0,0 +1,65 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb new file mode 100644 index 000000000..80fedae67 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb @@ -0,0 +1,73 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb new file mode 100644 index 000000000..eb3104dec --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb @@ -0,0 +1,73 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + 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 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 index f30638198..70f7caa20 100644 --- 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 @@ -339,4 +339,30 @@ def json(payload, status = 200) expect(client.delete_expected_payment('ep1')).to be(true) 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 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 index 813f1612b..29b9d8725 100644 --- 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 @@ -26,4 +26,11 @@ 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment' ) 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/approve_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/approve_payment_order_spec.rb new file mode 100644 index 000000000..785efa1c2 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/approve_payment_order_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/cancel_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order_spec.rb new file mode 100644 index 000000000..05b4a9c85 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/create_account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_account_holder_spec.rb new file mode 100644 index 000000000..f5a7ee632 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_account_holder_spec.rb @@ -0,0 +1,80 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/create_external_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_external_account_spec.rb new file mode 100644 index 000000000..3c307e35d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_external_account_spec.rb @@ -0,0 +1,41 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/create_internal_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_internal_account_spec.rb new file mode 100644 index 000000000..0d7414edb --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_internal_account_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/create_payment_order_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_payment_order_spec.rb new file mode 100644 index 000000000..27d8bdb81 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_payment_order_spec.rb @@ -0,0 +1,70 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/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/support.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb new file mode 100644 index 000000000..e7afc1945 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb @@ -0,0 +1,37 @@ +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 + end +end diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification_spec.rb new file mode 100644 index 000000000..57d2cff17 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification_spec.rb @@ -0,0 +1,87 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/update_account_holder_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_account_holder_spec.rb new file mode 100644 index 000000000..7acaa3d31 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_account_holder_spec.rb @@ -0,0 +1,78 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/update_external_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_external_account_spec.rb new file mode 100644 index 000000000..b0ac14c20 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_external_account_spec.rb @@ -0,0 +1,31 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/update_internal_account_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_internal_account_spec.rb new file mode 100644 index 000000000..271ced24d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_internal_account_spec.rb @@ -0,0 +1,26 @@ +RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::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/spec_helper.rb b/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb index 18da35d47..d9de425f2 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mambu_payments/spec/spec_helper.rb @@ -16,7 +16,9 @@ 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) From 8b09be58f47fe2950438b64d649b64baebf16621 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 18 May 2026 15:59:40 +0200 Subject: [PATCH 05/24] feat(mambu_payments): add events and files collections Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 6 + .../collections/event.rb | 163 +++++++++++++++++ .../collections/file.rb | 115 ++++++++++++ .../datasource.rb | 2 + .../client_spec.rb | 26 +++ .../collections/event_spec.rb | 170 ++++++++++++++++++ .../collections/file_spec.rb | 124 +++++++++++++ .../datasource_spec.rb | 3 +- 8 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/event.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/file.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/event_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/file_spec.rb 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 index 5e2102881..ff2c440e4 100644 --- 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 @@ -30,6 +30,12 @@ def find_direct_debit_mandate(id) = get_resource('direct_debit_mandates', 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) end end end 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..eaf133770 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/event.rb @@ -0,0 +1,163 @@ +# rubocop:disable Metrics/ClassLength +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 + + FETCHERS = { + 'MambuPaymentOrder' => :find_payment_order, + 'MambuTransaction' => :find_transaction, + 'MambuIncomingPayment' => :find_incoming_payment, + 'MambuExpectedPayment' => :find_expected_payment, + 'MambuDirectDebitMandate' => :find_direct_debit_mandate, + 'MambuBalance' => :find_balance, + 'MambuConnectedAccount' => :find_connected_account, + 'MambuAccountHolder' => :find_account_holder, + 'MambuInternalAccount' => :find_internal_account, + 'MambuExternalAccount' => :find_external_account + }.freeze + + def initialize(datasource) + super(datasource, 'MambuEvent') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 + + def aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_event(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_events(page: page, limit: per_page) + end + + # PolymorphicManyToOne is not resolved by the customizer, so we populate + # `related_object` here when the projection requests it. Records are grouped + # by their (translated) related_object_type so each target collection is + # queried in a single batched pass. + def embed_relations(rows, records, projection) + return if projection.nil? || !relations_in(projection).include?('related_object') + + sources = records.map { |r| attrs_of(r) } + grouped = group_by_collection(sources) + + caches = grouped.transform_values do |entries| + collection_name = entries.first[:collection_name] + fetcher = FETCHERS[collection_name] + serializer = datasource.get_collection(collection_name) + ids = entries.map { |e| e[:id] }.uniq + ids.to_h { |id| [id, datasource.client.public_send(fetcher, id)] } + .compact + .transform_values { |raw| serializer.serialize(raw) } + end + + 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.nil? || id.to_s.empty? + + row['related_object'] = caches.dig(type, id) + end + end + + def group_by_collection(sources) + sources.each_with_object({}) do |src, acc| + type = TYPE_TO_COLLECTION[src['related_object_type']] + id = src['related_object_id'] + next if type.nil? || id.nil? || id.to_s.empty? + + (acc[type] ||= []) << { collection_name: type, id: id } + end + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('topic', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('related_object_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('related_object_type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('webhook_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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 + +# rubocop:enable Metrics/ClassLength 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..3dca03d43 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/file.rb @@ -0,0 +1,115 @@ +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 + + def initialize(datasource) + super(datasource, 'MambuFile') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_file(id) } if ids + + page, per_page = translate_page(filter.page) + datasource.client.list_files(page: page, limit: per_page) + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: true)) + add_field('category', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('filename', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('size', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: true)) + add_field('summary', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, + is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb index 50ae8b18d..2d37420ce 100644 --- 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 @@ -23,6 +23,8 @@ def register_collections 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)) end end 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 index 70f7caa20..24ce8a452 100644 --- 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 @@ -340,6 +340,32 @@ def json(payload, status = 200) 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 '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')) 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..fd317171f --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/event_spec.rb @@ -0,0 +1,170 @@ +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' 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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_events).and_return([payment_order_event, transaction_event]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + 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..c7d666dc9 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/file_spec.rb @@ -0,0 +1,124 @@ +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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_files).and_return([file_record, file_record]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + 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 index 29b9d8725..4b25f0360 100644 --- 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 @@ -23,7 +23,8 @@ expect(ds.collections.keys).to contain_exactly( 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', - 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment' + 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', + 'MambuEvent', 'MambuFile' ) end From 8860314b83da30351515a45d27fae854d7804abc Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 19 May 2026 16:41:57 +0200 Subject: [PATCH 06/24] fix(mambu_payments): raise on unsupported filter predicates instead of dropping them Previously, any condition tree leaf other than `id = X` / `id IN [...]` was silently ignored in `fetch_records`, so non-id filters (status, segments, date ranges) returned an unfiltered page, producing wrong counts and missing rows. The new `Query::ConditionTreeTranslator` translates the tree into Numeral query params or raises `UnsupportedOperatorError`, and each collection declares its server-filterable fields via `api_filters`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/account_holder.rb | 3 +- .../collections/balance.rb | 9 +- .../collections/base_collection.rb | 13 ++ .../collections/connected_account.rb | 3 +- .../collections/direct_debit_mandate.rb | 10 +- .../collections/event.rb | 3 +- .../collections/expected_payment.rb | 11 +- .../collections/external_account.rb | 9 +- .../collections/file.rb | 9 +- .../collections/incoming_payment.rb | 11 +- .../collections/internal_account.rb | 9 +- .../collections/payment_order.rb | 13 +- .../collections/transaction.rb | 11 +- .../query/condition_tree_translator.rb | 115 ++++++++++++++++++ .../collections/base_collection_spec.rb | 12 ++ .../collections/payment_order_spec.rb | 20 +++ .../query/condition_tree_translator_spec.rb | 96 +++++++++++++++ 17 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/query/condition_tree_translator.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/query/condition_tree_translator_spec.rb 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 index 0da7b23c5..02de89d78 100644 --- 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 @@ -53,7 +53,8 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_account_holder(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_account_holders(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_account_holders(**params) end def build_payload(data) 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 index 30bd6c8af..8d4baf615 100644 --- 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 @@ -48,7 +48,14 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_balance(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_balances(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_balances(**params) + end + + def api_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def embed_relations(rows, records, projection) 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 index 7d2d45344..8edea9f92 100644 --- 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 @@ -37,6 +37,19 @@ def extract_id_lookup(node) end end + # Server-filterable fields the Numeral API accepts for this collection. + # Subclasses override with entries like: + # { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } + # Anything not declared here raises UnsupportedOperatorError when filtered + # on, so we never silently return unfiltered results. + def api_filters + {} + 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? 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 index 22c4dd486..73ad70bd3 100644 --- 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 @@ -75,7 +75,8 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_connected_account(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_connected_accounts(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_connected_accounts(**params) end def build_payload(data) 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 index fb63d3804..323fec9d8 100644 --- 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 @@ -73,7 +73,15 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_direct_debit_mandate(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_direct_debit_mandates(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_direct_debit_mandates(**params) + end + + def api_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, + 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def build_payload(data) 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 index eaf133770..83d8a3c22 100644 --- 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 @@ -79,7 +79,8 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_event(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_events(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_events(**params) end # PolymorphicManyToOne is not resolved by the customizer, so we populate 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 index bd99eeeb7..ee648c009 100644 --- 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 @@ -74,7 +74,16 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_expected_payment(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_expected_payments(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_expected_payments(**params) + end + + def api_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 build_payload(data) 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 index a2efc7fb8..5ca785e3c 100644 --- 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 @@ -74,7 +74,14 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_external_account(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_external_accounts(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_external_accounts(**params) + end + + def api_filters + { + 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def build_payload(data) 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 index 3dca03d43..06c29aadb 100644 --- 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 @@ -53,7 +53,14 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_file(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_files(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_files(**params) + end + + def api_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def embed_relations(rows, records, projection) 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 index c5ca0f4cb..e35ba2879 100644 --- 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 @@ -60,7 +60,16 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_incoming_payment(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_incoming_payments(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_incoming_payments(**params) + end + + def api_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 embed_relations(rows, records, projection) 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 index 1ce3bbc94..cce1fc4d3 100644 --- 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 @@ -80,7 +80,14 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_internal_account(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_internal_accounts(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_internal_accounts(**params) + end + + def api_filters + { + 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def build_payload(data) 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 index fcdb2cc68..264b3d1c8 100644 --- 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 @@ -73,7 +73,18 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_payment_order(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_payment_orders(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_payment_orders(**params) + end + + # 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 api_filters + { + 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + } end def build_payload(data) 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 index 046ad3db2..e1a26a9ac 100644 --- 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 @@ -60,7 +60,16 @@ def fetch_records(_caller, filter) return ids.filter_map { |id| datasource.client.find_transaction(id) } if ids page, per_page = translate_page(filter.page) - datasource.client.list_transactions(page: page, limit: per_page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_transactions(**params) + end + + def api_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 embed_relations(rows, records, projection) 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/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 index cf0e9c140..8851958b9 100644 --- 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 @@ -165,6 +165,18 @@ module ForestAdminDatasourceMambuPayments 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 any non-id predicate by default (api_filters is empty on the base class)' do + leaf = Leaf.new('connected_account_id', 'equal', 'acc1') + expect { collection.send(:translate_filters, leaf) } + .to raise_error(ForestAdminDatasourceMambuPayments::UnsupportedOperatorError) + end + end + describe '#aggregate' do let(:filter) { ForestAdminDatasourceToolkit::Components::Query::Filter.new } 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 index 659b56a77..45ced6152 100644 --- 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 @@ -95,6 +95,26 @@ module ForestAdminDatasourceMambuPayments 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', page: 1)) + 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 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 From c76ea882900155bf20e1048be441af68d8a617b5 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 20 May 2026 11:48:59 +0200 Subject: [PATCH 07/24] feat(mambu_payments): add returns collection Wires the Numeral /returns resource: list/find/create (POST with related_payment_id + return_reason) and update (status/status_details or metadata). No delete: Numeral has no DELETE on /returns; lifecycle transitions are side-effect actions left for a future smart-action plugin. Only the connected_account ManyToOne is declared; the related_payment_id field is polymorphic in Numeral (payment_order or incoming_payment) and is exposed as a plain column. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 3 + .../client/writes.rb | 5 + .../collections/return.rb | 184 ++++++++++++++ .../datasource.rb | 1 + .../client_spec.rb | 23 ++ .../collections/return_spec.rb | 233 ++++++++++++++++++ .../datasource_spec.rb | 2 +- 7 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/return.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/return_spec.rb 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 index ff2c440e4..06ec566a4 100644 --- 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 @@ -36,6 +36,9 @@ 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) 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 index e5e66ba00..a2e93bd7e 100644 --- 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 @@ -31,6 +31,11 @@ def delete_direct_debit_mandate(id) = delete_resource('direct_debit_manda 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) 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..0e4b000a7 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/return.rb @@ -0,0 +1,184 @@ +# 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 + + def initialize(datasource) + super(datasource, 'MambuReturn') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_return(id) } if ids + + page, per_page = translate_page(filter.page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_returns(**params) + end + + def api_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 + + # The Numeral create payload is narrow (related_payment_id, return_reason, + # related_payment_suspended, metadata); update is even narrower (status + + # status_details, OR metadata). We blacklist system-managed/read-only + # fields rather than whitelisting so a future writable field doesn't get + # silently swallowed if we forget to update this list. + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[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].each do |k| + attrs.delete(k) + end + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('related_payment_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_RELATED_PAYMENT, + is_read_only: true, is_sortable: false)) + add_field('related_payment_suspended', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, + is_read_only: false, is_sortable: false)) + add_field('return_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_RETURN_TYPE, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, is_read_only: false, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('return_reason', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('aggregation_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('file_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb index 2d37420ce..8fd040080 100644 --- 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 @@ -25,6 +25,7 @@ def register_collections add_collection(Collections::ExpectedPayment.new(self)) add_collection(Collections::Event.new(self)) add_collection(Collections::File.new(self)) + add_collection(Collections::Return.new(self)) end end 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 index 24ce8a452..a111bd2a7 100644 --- 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 @@ -366,6 +366,29 @@ def json(payload, status = 200) 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')) 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..a9aa79d2d --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/return_spec.rb @@ -0,0 +1,233 @@ +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', page: 1)) + 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 '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_returns).and_return([return_record, return_record]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + 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/datasource_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb index 4b25f0360..f2776d013 100644 --- 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 @@ -24,7 +24,7 @@ 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', - 'MambuEvent', 'MambuFile' + 'MambuEvent', 'MambuFile', 'MambuReturn' ) end From 83bab8c46c3d89b7f9d592e33fbf3a65bdd0b83a Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 20 May 2026 14:35:08 +0200 Subject: [PATCH 08/24] feat(mambu_payments): add claims collection Wires the Numeral /claims resource as a read-only collection (list + find) with a ManyToOne to connected_account. Numeral has no POST, PATCH or DELETE on /claims from the business API: claims arrive from the counterparty bank (or the sandbox simulator), and accept/reject are lifecycle actions left for a future smart-action plugin. The related_payment_id field is polymorphic (payment_order or incoming_payment) and is exposed as a plain column rather than a typed relation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 6 + .../collections/claim.rb | 131 +++++++++++++ .../datasource.rb | 1 + .../client_spec.rb | 13 ++ .../collections/claim_spec.rb | 177 ++++++++++++++++++ .../datasource_spec.rb | 2 +- 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/claim.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/claim_spec.rb 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 index 06ec566a4..a0de5618c 100644 --- 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 @@ -39,6 +39,12 @@ 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) end 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..31e772424 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/claim.rb @@ -0,0 +1,131 @@ +# rubocop:disable Metrics/ClassLength +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 + + def initialize(datasource) + super(datasource, 'MambuClaim') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_claim(id) } if ids + + page, per_page = translate_page(filter.page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_claims(**params) + end + + def api_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 embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + # 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', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, is_read_only: true, is_sortable: true)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reason', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('related_payment_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('related_payment', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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 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 index 8fd040080..23c0062e9 100644 --- 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 @@ -26,6 +26,7 @@ def register_collections add_collection(Collections::Event.new(self)) add_collection(Collections::File.new(self)) add_collection(Collections::Return.new(self)) + add_collection(Collections::Claim.new(self)) end end 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 index a111bd2a7..34aca1c40 100644 --- 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 @@ -366,6 +366,19 @@ def json(payload, status = 200) 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 'returns' do it 'list_returns hits /returns' do stub_request(:get, "#{base}/returns").to_return(json('records' => [])) 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..97ede2f6a --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/claim_spec.rb @@ -0,0 +1,177 @@ +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', page: 1)) + 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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_claims).and_return([claim, claim]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + 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 index f2776d013..532934863 100644 --- 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 @@ -24,7 +24,7 @@ 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', - 'MambuEvent', 'MambuFile', 'MambuReturn' + 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim' ) end From 269a147d5a300a645a41c9f1a8bdbf46082749ce Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 20 May 2026 16:17:50 +0200 Subject: [PATCH 09/24] refactor(mambu_payments): make connected_account read-only Numeral does not expose POST, PATCH or DELETE on /connected_accounts: accounts are provisioned out-of-band by the bank or via the Mambu Payments dashboard. Strips the create/update/delete methods from the collection, drops the matching client wrappers, and flips every previously writable column to is_read_only: true so Forest no longer advertises mutations the API would reject. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/writes.rb | 4 -- .../collections/connected_account.rb | 63 +++++++------------ .../client_spec.rb | 29 --------- .../collections/connected_account_spec.rb | 57 ++++------------- 4 files changed, 33 insertions(+), 120 deletions(-) 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 index a2e93bd7e..2a5cc814c 100644 --- 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 @@ -1,10 +1,6 @@ module ForestAdminDatasourceMambuPayments class Client module Writes - def create_connected_account(attrs) = post_resource('connected_accounts', attrs) - def update_connected_account(id, attrs) = patch_resource('connected_accounts', id, attrs) - def delete_connected_account(id) = delete_resource('connected_accounts', id) - 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) 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 index 73ad70bd3..0050114cf 100644 --- 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 @@ -16,19 +16,6 @@ def list(caller, filter, projection) records.map { |r| project(serialize(r), projection) } end - def create(_caller, data) - serialize(datasource.client.create_connected_account(build_payload(data))) - end - - def update(caller, filter, patch) - payload = build_payload(patch) - ids_for(caller, filter).each { |id| datasource.client.update_connected_account(id, payload) } - end - - def delete(caller, filter) - ids_for(caller, filter).each { |id| datasource.client.delete_connected_account(id) } - end - def serialize(record) a = attrs_of(record) { @@ -79,65 +66,59 @@ def fetch_records(_caller, filter) datasource.client.list_connected_accounts(**params) end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object created_at disabled_at bank_data].each { |k| attrs.delete(k) } - attrs - end - def define_schema add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_primary_key: true, is_read_only: true, is_sortable: true)) add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: true, is_sortable: false)) add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) + is_read_only: true, is_sortable: true)) add_field('distinguished_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) + is_read_only: true, is_sortable: true)) add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('settlement_account', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('legal_entity_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('receiving_agent', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('services_activated', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('file_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('return_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('incoming_instant_payment_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], is_read_only: true, is_sortable: false)) add_field('account_number_generation_settings', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, is_read_only: true, is_sortable: true)) add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, 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 index 34aca1c40..de2156997 100644 --- 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 @@ -108,35 +108,6 @@ def json(payload, status = 200) end end - describe '#create_connected_account' do - it 'POSTs the payload as JSON and returns the response body' do - stub_request(:post, "#{base}/connected_accounts") - .with(body: { 'name' => 'Acme' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - .to_return(json('id' => 'new', 'name' => 'Acme')) - - expect(client.create_connected_account('name' => 'Acme')).to include('id' => 'new') - end - end - - describe '#update_connected_account' do - it 'PATCHes the payload to /resource/:id' do - stub_request(:patch, "#{base}/connected_accounts/abc") - .with(body: { 'name' => 'NewName' }.to_json) - .to_return(json('id' => 'abc', 'name' => 'NewName')) - - expect(client.update_connected_account('abc', 'name' => 'NewName')) - .to include('name' => 'NewName') - end - end - - describe '#delete_connected_account' do - it 'DELETEs /resource/:id and returns true on success' do - stub_request(:delete, "#{base}/connected_accounts/abc").to_return(status: 204, body: '') - expect(client.delete_connected_account('abc')).to be(true) - 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' => [])) 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 index 2efdd50f4..4ac3acba0 100644 --- 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 @@ -43,9 +43,17 @@ module ForestAdminDatasourceMambuPayments end end - it 'marks id, disabled_at, created_at as read-only' do - f = collection.schema[:fields] - %w[id disabled_at created_at].each { |k| expect(f[k].is_read_only).to be(true) } + 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 @@ -93,48 +101,5 @@ module ForestAdminDatasourceMambuPayments expect(result.first['value']).to eq(2) end end - - describe '#create' do - it 'POSTs the payload stripping system-managed fields' do - allow(client).to receive(:create_connected_account) do |payload| - expect(payload).to include('name' => 'New') - expect(payload.keys).not_to include('id', 'object', 'created_at', 'disabled_at', 'bank_data') - { 'id' => 'new', 'name' => 'New' } - end - - result = collection.create(nil, - 'id' => 'ignored', 'object' => 'connected_account', - 'created_at' => 't', 'disabled_at' => 't', - 'bank_data' => {}, 'name' => 'New') - expect(result['id']).to eq('new') - end - end - - describe '#update' do - it 'PATCHes every id resolved by the filter' do - # ids_for goes through fetch_records, which calls find_* per id. - allow(client).to receive(:find_connected_account).with('a').and_return('id' => 'a') - allow(client).to receive(:find_connected_account).with('b').and_return('id' => 'b') - allow(client).to receive(:update_connected_account) - - collection.update(nil, - Filter.new(condition_tree: Leaf.new('id', 'in', %w[a b])), - 'name' => 'Renamed') - - expect(client).to have_received(:update_connected_account).with('a', hash_including('name' => 'Renamed')) - expect(client).to have_received(:update_connected_account).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_connected_account).with('a').and_return('id' => 'a') - allow(client).to receive(:delete_connected_account) - - collection.delete(nil, Filter.new(condition_tree: Leaf.new('id', 'equal', 'a'))) - - expect(client).to have_received(:delete_connected_account).with('a') - end - end end end From 54bc2075669031230e1ec6780f5603d8f06bfc88 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 20 May 2026 16:18:03 +0200 Subject: [PATCH 10/24] refactor(mambu_payments): drop transaction account snapshots in favor of relations The transaction collection already declares ManyToOne relations to internal_account and external_account via internal_account_id / external_account_id, and embed_many_to_one wires them at list time. The internal_account_snapshot / external_account_snapshot Json columns were redundant truncated copies of the same data. Removed from both serialize and the schema; consumers should follow the relation instead to get the full account record. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/transaction.rb | 10 ++-------- .../collections/transaction_spec.rb | 8 +++++++- 2 files changed, 9 insertions(+), 9 deletions(-) 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 index e1a26a9ac..3e31167a4 100644 --- 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 @@ -1,4 +1,4 @@ -# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class Transaction < BaseCollection @@ -34,8 +34,6 @@ def serialize(record) 'structured_reference' => a['structured_reference'], 'value_date' => a['value_date'], 'booking_date' => a['booking_date'], - 'internal_account_snapshot' => a['internal_account'], - 'external_account_snapshot' => a['external_account'], 'internal_account_id' => a['internal_account_id'], 'external_account_id' => a['external_account_id'], 'uetr' => a['uetr'], @@ -120,10 +118,6 @@ def define_schema is_read_only: true, is_sortable: true)) add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, is_read_only: true, is_sortable: true)) - add_field('internal_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('external_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: true, is_sortable: false)) add_field('external_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, @@ -163,4 +157,4 @@ def define_relations end end -# rubocop:enable Metrics/ClassLength, Metrics/MethodLength +# rubocop:enable Metrics/ClassLength 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 index 0767d67e7..5ad250b1e 100644 --- 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 @@ -43,7 +43,6 @@ module ForestAdminDatasourceMambuPayments expect(keys).to include( 'id', 'connected_account_id', 'category', 'direction', 'amount', 'currency', 'description', 'structured_reference', 'value_date', 'booking_date', - 'internal_account_snapshot', 'external_account_snapshot', 'internal_account_id', 'external_account_id', 'uetr', 'bank_data', 'reconciliation_status', 'reconciled_amount', 'custom_fields', 'created_at' @@ -56,6 +55,13 @@ module ForestAdminDatasourceMambuPayments .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) From 174483ef08b80ecdaa290eae057b605cf329d0bc Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 21 May 2026 11:07:42 +0200 Subject: [PATCH 11/24] feat(mambu_payments): add reconciliations collection Wires the Numeral /reconciliations resource: list/find/create/update with a ManyToOne to transaction. No delete (Numeral has no DELETE on /reconciliations) and no cancel action (lifecycle action left for a future smart-action plugin). The payment_id field is polymorphic in Numeral (payment_order, incoming_payment, return, expected_payment or payment_capture, discriminated by payment_type) and is exposed as a plain column rather than a typed relation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 3 + .../client/writes.rb | 5 + .../collections/reconciliation.rb | 136 ++++++++++++ .../datasource.rb | 1 + .../client_spec.rb | 23 ++ .../collections/reconciliation_spec.rb | 209 ++++++++++++++++++ .../datasource_spec.rb | 2 +- 7 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/reconciliation_spec.rb 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 index a0de5618c..de5c26003 100644 --- 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 @@ -45,6 +45,9 @@ def find_return(id) = get_resource('returns', id) # 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) 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 index 2a5cc814c..896f4c7b5 100644 --- 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 @@ -32,6 +32,11 @@ def delete_expected_payment(id) = delete_resource('expected_payments', id # 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/reconciliation.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb new file mode 100644 index 000000000..2ba46b61b --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb @@ -0,0 +1,136 @@ +# rubocop:disable Metrics/ClassLength +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 + + def initialize(datasource) + super(datasource, 'MambuReconciliation') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_reconciliation(id) } if ids + + page, per_page = translate_page(filter.page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_reconciliations(**params) + end + + def api_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 + + # Numeral's create payload is narrow (transaction_id required, payment_id / + # amount / metadata optional). Update only accepts metadata. We blacklist + # the system-managed fields so the same helper can serve both calls. + def build_payload(data) + attrs = data.transform_keys(&:to_s) + %w[id object match_type canceled_at created_at].each { |k| attrs.delete(k) } + attrs + end + + def embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + tx = datasource.get_collection('MambuTransaction') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'transaction_id', relation_name: 'transaction', + fetcher: ->(id) { datasource.client.find_transaction(id) }, + serializer: ->(raw) { tx.serialize(raw) } + ) + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + is_read_only: false, is_sortable: false)) + add_field('payment_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_PAYMENT_TYPE, + is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: false, is_sortable: false)) + add_field('match_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_MATCH_TYPE, + is_read_only: true, is_sortable: true)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: false, is_sortable: false)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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 +# rubocop:enable Metrics/ClassLength 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 index 23c0062e9..c8f6c8c2c 100644 --- 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 @@ -27,6 +27,7 @@ def register_collections add_collection(Collections::File.new(self)) add_collection(Collections::Return.new(self)) add_collection(Collections::Claim.new(self)) + add_collection(Collections::Reconciliation.new(self)) end end 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 index de2156997..0344c910c 100644 --- 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 @@ -337,6 +337,29 @@ def json(payload, status = 200) 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' => [])) 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..23a2b2e1f --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/reconciliation_spec.rb @@ -0,0 +1,209 @@ +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', page: 1)) + 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 '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_reconciliations).and_return([reconciliation, reconciliation]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + 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/datasource_spec.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/datasource_spec.rb index 532934863..42308accb 100644 --- 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 @@ -24,7 +24,7 @@ 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', - 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim' + 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim', 'MambuReconciliation' ) end From ab23351fef19ef2940e893bd88ad63b97d1a9290 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 21 May 2026 14:33:44 +0200 Subject: [PATCH 12/24] feat(mambu_payments): add payment_captures collection Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 6 + .../collections/payment_capture.rb | 177 ++++++++++++++++ .../datasource.rb | 1 + .../client_spec.rb | 13 ++ .../collections/payment_capture_spec.rb | 195 ++++++++++++++++++ .../datasource_spec.rb | 3 +- 6 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_capture_spec.rb 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 index de5c26003..a35ed041b 100644 --- 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 @@ -48,6 +48,12 @@ 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) 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..12bd38388 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb @@ -0,0 +1,177 @@ +# 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 + + def initialize(datasource) + super(datasource, 'MambuPaymentCapture') + define_schema + define_relations + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(caller, filter) + rows = records.map { |r| project(serialize(r), projection) } + embed_relations(rows, records, projection) + rows + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_payment_capture(id) } if ids + + page, per_page = translate_page(filter.page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_payment_captures(**params) + end + + def api_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 embed_relations(rows, records, projection) + sources = records.map { |r| attrs_of(r) } + ca = datasource.get_collection('MambuConnectedAccount') + embed_many_to_one( + rows, sources, projection, + foreign_key: 'connected_account_id', relation_name: 'connected_account', + fetcher: ->(id) { datasource.client.find_connected_account(id) }, + serializer: ->(raw) { ca.serialize(raw) } + ) + end + + # 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', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) + add_field('source', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_SOURCE, is_read_only: true, is_sortable: true)) + add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('original_payment_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('remittance_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('remittance_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('transaction_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('authorization_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('payment_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('network', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('merchant_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('fee_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('fee_amount_currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('net_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('net_amount_currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('reconciliation_status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_RECONCILIATION_STATUS, + is_read_only: true, is_sortable: true)) + add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('cbs_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('lending', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb index c8f6c8c2c..409006c5e 100644 --- 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 @@ -28,6 +28,7 @@ def register_collections add_collection(Collections::Return.new(self)) add_collection(Collections::Claim.new(self)) add_collection(Collections::Reconciliation.new(self)) + add_collection(Collections::PaymentCapture.new(self)) end end 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 index 0344c910c..7b91be8b4 100644 --- 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 @@ -373,6 +373,19 @@ def json(payload, status = 200) 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 'returns' do it 'list_returns hits /returns' do stub_request(:get, "#{base}/returns").to_return(json('records' => [])) 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..a339deaa5 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payment_capture_spec.rb @@ -0,0 +1,195 @@ +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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_payment_captures).and_return([payment_capture, payment_capture]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + 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 index 42308accb..9065e1f66 100644 --- 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 @@ -24,7 +24,8 @@ 'MambuConnectedAccount', 'MambuPaymentOrder', 'MambuTransaction', 'MambuBalance', 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', - 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim', 'MambuReconciliation' + 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim', 'MambuReconciliation', + 'MambuPaymentCapture' ) end From cb4a67557be5e1da0a7a1a2b956402155de1443d Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 21 May 2026 15:20:31 +0200 Subject: [PATCH 13/24] feat(mambu_payments): add payee_verification_requests collection Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client/reads.rb | 7 + .../collections/payee_verification_request.rb | 114 ++++++++++++ .../datasource.rb | 1 + .../client_spec.rb | 13 ++ .../payee_verification_request_spec.rb | 176 ++++++++++++++++++ .../datasource_spec.rb | 2 +- 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payee_verification_request_spec.rb 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 index a35ed041b..188d05d54 100644 --- 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 @@ -54,6 +54,13 @@ def find_reconciliation(id) = get_resource('reconciliations', id) # 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/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..859ca1a73 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb @@ -0,0 +1,114 @@ +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 + + def initialize(datasource) + super(datasource, 'MambuPayeeVerificationRequest') + define_schema + enable_count + end + + def list(caller, filter, projection) + fetch_records(caller, filter).map { |r| project(serialize(r), projection) } + 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 aggregate_count(caller, filter) + list(caller, filter, ['id']).size + end + + private + + def fetch_records(_caller, filter) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_payee_verification_request(id) } if ids + + page, per_page = translate_page(filter.page) + params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) + datasource.client.list_payee_verification_requests(**params) + end + + def api_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 + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, is_read_only: true, is_sortable: true)) + add_field('failure_code', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_FAILURE_CODE, + is_read_only: true, is_sortable: false)) + add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_DIRECTION, + is_read_only: true, is_sortable: true)) + add_field('scheme', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + 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', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('matching_result', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_MATCHING_RESULT, + is_read_only: true, is_sortable: true)) + add_field('payee_suggested_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('matching_details', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('scheme_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('response_received_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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/datasource.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/datasource.rb index 409006c5e..282ac5df8 100644 --- 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 @@ -29,6 +29,7 @@ def register_collections 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/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 index 7b91be8b4..6e58b756a 100644 --- 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 @@ -386,6 +386,19 @@ def json(payload, status = 200) 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' => [])) 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..8094eecd2 --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/collections/payee_verification_request_spec.rb @@ -0,0 +1,176 @@ +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 + + describe '#aggregate Count' do + it 'counts via list with a minimal projection' do + allow(client).to receive(:list_payee_verification_requests) + .and_return([payee_verification_request, payee_verification_request]) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + 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 index 9065e1f66..bb4ebb02d 100644 --- 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 @@ -25,7 +25,7 @@ 'MambuAccountHolder', 'MambuExternalAccount', 'MambuInternalAccount', 'MambuIncomingPayment', 'MambuDirectDebitMandate', 'MambuExpectedPayment', 'MambuEvent', 'MambuFile', 'MambuReturn', 'MambuClaim', 'MambuReconciliation', - 'MambuPaymentCapture' + 'MambuPaymentCapture', 'MambuPayeeVerificationRequest' ) end From 628e0379e1670be8e2fac1a778cdd09920be0709 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 21 May 2026 17:07:41 +0200 Subject: [PATCH 14/24] feat(mambu_payments): add account-holder relation plugins Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/approve_payment_order.rb | 54 -------- .../plugins/cancel_payment_order.rb | 64 ---------- .../plugins/create_account_holder.rb | 42 ------- .../plugins/create_external_account.rb | 52 -------- .../plugins/create_internal_account.rb | 56 --------- .../plugins/create_payment_order.rb | 64 ---------- ...account_holder_to_direct_debit_mandates.rb | 50 ++++++++ ...ink_account_holder_to_incoming_payments.rb | 50 ++++++++ .../relations/two_step_holder_filter.rb | 52 ++++++++ .../smart_actions/approve_payment_order.rb | 56 +++++++++ .../smart_actions/cancel_payment_order.rb | 66 ++++++++++ .../smart_actions/create_account_holder.rb | 44 +++++++ .../smart_actions/create_external_account.rb | 54 ++++++++ .../smart_actions/create_internal_account.rb | 58 +++++++++ .../smart_actions/create_payment_order.rb | 66 ++++++++++ .../trigger_payee_verification.rb | 58 +++++++++ .../smart_actions/update_account_holder.rb | 67 ++++++++++ .../smart_actions/update_external_account.rb | 75 +++++++++++ .../smart_actions/update_internal_account.rb | 75 +++++++++++ .../plugins/trigger_payee_verification.rb | 56 --------- .../plugins/update_account_holder.rb | 65 ---------- .../plugins/update_external_account.rb | 73 ----------- .../plugins/update_internal_account.rb | 73 ----------- ...nt_holder_to_direct_debit_mandates_spec.rb | 95 ++++++++++++++ ...ccount_holder_to_incoming_payments_spec.rb | 116 ++++++++++++++++++ .../approve_payment_order_spec.rb | 2 +- .../cancel_payment_order_spec.rb | 2 +- .../create_account_holder_spec.rb | 2 +- .../create_external_account_spec.rb | 2 +- .../create_internal_account_spec.rb | 2 +- .../create_payment_order_spec.rb | 2 +- .../trigger_payee_verification_spec.rb | 2 +- .../update_account_holder_spec.rb | 2 +- .../update_external_account_spec.rb | 2 +- .../update_internal_account_spec.rb | 2 +- .../plugins/support.rb | 76 ++++++++++++ 36 files changed, 1068 insertions(+), 609 deletions(-) delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_holder_filter.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb delete mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments_spec.rb rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/approve_payment_order_spec.rb (96%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/cancel_payment_order_spec.rb (95%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/create_account_holder_spec.rb (97%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/create_external_account_spec.rb (95%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/create_internal_account_spec.rb (95%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/create_payment_order_spec.rb (97%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/trigger_payee_verification_spec.rb (97%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/update_account_holder_spec.rb (97%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/update_external_account_spec.rb (93%) rename packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/{ => smart_actions}/update_internal_account_spec.rb (92%) diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb deleted file mode 100644 index 5fd28d4f8..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/approve_payment_order.rb +++ /dev/null @@ -1,54 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb deleted file mode 100644 index 9fc6339f7..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order.rb +++ /dev/null @@ -1,64 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb deleted file mode 100644 index 4a8b730a5..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_account_holder.rb +++ /dev/null @@ -1,42 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb deleted file mode 100644 index a5819a611..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_external_account.rb +++ /dev/null @@ -1,52 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb deleted file mode 100644 index f9b64d221..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_internal_account.rb +++ /dev/null @@ -1,56 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb deleted file mode 100644 index 19c141163..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/create_payment_order.rb +++ /dev/null @@ -1,64 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 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..119722d04 --- /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,50 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable AccountHolder <-> DirectDebitMandate link. + # The chain is transitive: DDM -> external_account -> account_holder. + # See TwoStepHolderFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToDirectDebitMandates, + # {} + # ) + class LinkAccountHolderToDirectDebitMandates < ForestAdminDatasourceCustomizer::Plugins::Plugin + DIRECT_DEBIT_MANDATE = 'MambuDirectDebitMandate'.freeze + EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze + ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze + FK_NAME = 'account_holder_id'.freeze + LOCAL_FK = 'external_account_id'.freeze + IMPORT_PATH = 'external_account:account_holder_id'.freeze + MANY_TO_ONE_NAME = 'account_holder'.freeze + ONE_TO_MANY_NAME = 'direct_debit_mandates'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkAccountHolderToDirectDebitMandates must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(DIRECT_DEBIT_MANDATE) do |c| + c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) + c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, + foreign_key: FK_NAME, + foreign_key_target: 'id') + TwoStepHolderFilter.install(c, + fk_name: FK_NAME, + local_fk: LOCAL_FK, + intermediate_collection: EXTERNAL_ACCOUNT) + end + + datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, DIRECT_DEBIT_MANDATE, + 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_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..e35ef33af --- /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,50 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable AccountHolder <-> IncomingPayment link. + # The chain is transitive: IP -> internal_account -> account_holder. + # See TwoStepHolderFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToIncomingPayments, + # {} + # ) + class LinkAccountHolderToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze + FK_NAME = 'account_holder_id'.freeze + LOCAL_FK = 'internal_account_id'.freeze + IMPORT_PATH = 'internal_account:account_holder_id'.freeze + MANY_TO_ONE_NAME = 'account_holder'.freeze + ONE_TO_MANY_NAME = 'incoming_payments'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkAccountHolderToIncomingPayments must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| + c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) + c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, + foreign_key: FK_NAME, + foreign_key_target: 'id') + TwoStepHolderFilter.install(c, + fk_name: FK_NAME, + local_fk: LOCAL_FK, + intermediate_collection: INTERNAL_ACCOUNT) + end + + datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, INCOMING_PAYMENT, + 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/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..fc7b30fd2 --- /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,52 @@ +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)`. Only EQUAL/IN are handled (the operators + # Forest's OneToMany navigation actually uses). + module TwoStepHolderFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Projection = ForestAdminDatasourceToolkit::Components::Query::Projection + + # All-zero UUID: guaranteed not to exist in Numeral, so the native + # list returns []. Used to express "match nothing" without tripping + # the empty-IN guard in ConditionTreeTranslator. + NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze + + SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze + + def self.install(collection_customizer, fk_name:, local_fk:, intermediate_collection:) + SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + holder_ids = TwoStepHolderFilter.normalize(value, operator) + next TwoStepHolderFilter.no_match(local_fk) if holder_ids.empty? + + fk_ids = context.datasource.get_collection(intermediate_collection).list( + Filter.new(condition_tree: ConditionTreeLeaf.new(fk_name, Operators::IN, holder_ids)), + Projection.new(['id']) + ).map { |r| r['id'] }.uniq + + next TwoStepHolderFilter.no_match(local_fk) if fk_ids.empty? + + ConditionTreeLeaf.new(local_fk, Operators::IN, fk_ids) + end + end + end + + def self.normalize(value, operator) + values = operator == Operators::IN ? Array(value) : [value] + values.compact.reject { |v| v.to_s.empty? }.uniq + end + + def self.no_match(local_fk) + ConditionTreeLeaf.new(local_fk, Operators::EQUAL, NO_MATCH_SENTINEL) + 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/plugins/trigger_payee_verification.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb deleted file mode 100644 index ca187580b..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification.rb +++ /dev/null @@ -1,56 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - # 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb deleted file mode 100644 index 08c908bae..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_account_holder.rb +++ /dev/null @@ -1,65 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb deleted file mode 100644 index 80fedae67..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_external_account.rb +++ /dev/null @@ -1,73 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 diff --git a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb deleted file mode 100644 index eb3104dec..000000000 --- a/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/update_internal_account.rb +++ /dev/null @@ -1,73 +0,0 @@ -module ForestAdminDatasourceMambuPayments - module Plugins - 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 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/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 similarity index 96% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/approve_payment_order_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order_spec.rb index 785efa1c2..70d9e89c5 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::ApprovePaymentOrder do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 95% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/cancel_payment_order_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order_spec.rb index 05b4a9c85..10ac67db3 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::CancelPaymentOrder do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 97% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_account_holder_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder_spec.rb index f5a7ee632..6efca96f9 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::CreateAccountHolder do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 95% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_external_account_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account_spec.rb index 3c307e35d..48cd7cf30 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::CreateExternalAccount do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 95% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_internal_account_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account_spec.rb index 0d7414edb..cd192f66c 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::CreateInternalAccount do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 97% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/create_payment_order_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order_spec.rb index 27d8bdb81..7f7eab783 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::CreatePaymentOrder do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 97% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/trigger_payee_verification_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification_spec.rb index 57d2cff17..3c6187415 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::TriggerPayeeVerification do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 97% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_account_holder_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder_spec.rb index 7acaa3d31..17dfd8077 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::UpdateAccountHolder do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 93% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_external_account_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account_spec.rb index b0ac14c20..3dfc3e7ce 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::UpdateExternalAccount do +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 } diff --git a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 similarity index 92% rename from packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/update_internal_account_spec.rb rename to packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account_spec.rb index 271ced24d..8a15daf82 100644 --- a/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/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 @@ -1,4 +1,4 @@ -RSpec.describe ForestAdminDatasourceMambuPayments::Plugins::UpdateInternalAccount do +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 } 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 index e7afc1945..7393823fe 100644 --- 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 @@ -33,5 +33,81 @@ 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, :many_to_one_relations, :one_to_many_relations, :operator_handlers + + def initialize + @imported_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_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 From 25d8f6511c801444a9798658654abc41a683c542 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 21 May 2026 17:56:43 +0200 Subject: [PATCH 15/24] feat(mambu_payments): add external-account relation plugins Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/payment_order.rb | 24 ++++++++++- ...ternal_account_to_direct_debit_mandates.rb | 32 ++++++++++++++ ...k_external_account_to_incoming_payments.rb | 32 ++++++++++++++ ...link_external_account_to_payment_orders.rb | 32 ++++++++++++++ .../collections/payment_order_spec.rb | 43 +++++++++++++++++-- ...l_account_to_direct_debit_mandates_spec.rb | 31 +++++++++++++ ...ernal_account_to_incoming_payments_spec.rb | 30 +++++++++++++ ...external_account_to_payment_orders_spec.rb | 30 +++++++++++++ 8 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders_spec.rb 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 index 264b3d1c8..2b845b3ce 100644 --- 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 @@ -38,6 +38,7 @@ def serialize(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'], @@ -83,13 +84,18 @@ def fetch_records(_caller, filter) # silently returning unfiltered results. def api_filters { - 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } + '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 build_payload(data) attrs = data.transform_keys(&:to_s) - %w[id status created_at value_date initiated_at reconciliation_status reconciled_amount].each do |k| + %w[id status created_at value_date initiated_at reconciliation_status reconciled_amount + receiving_account_id].each do |k| attrs.delete(k) end attrs @@ -97,6 +103,7 @@ def build_payload(data) def embed_relations(rows, records, projection) ca = datasource.get_collection('MambuConnectedAccount') + ea = datasource.get_collection('MambuExternalAccount') sources = records.map { |r| attrs_of(r) } embed_many_to_one( rows, sources, projection, @@ -104,6 +111,12 @@ def embed_relations(rows, records, projection) fetcher: ->(id) { datasource.client.find_connected_account(id) }, serializer: ->(raw) { ca.serialize(raw) } ) + embed_many_to_one( + rows, sources, projection, + foreign_key: 'receiving_account_id', relation_name: 'external_account', + fetcher: ->(id) { datasource.client.find_external_account(id) }, + serializer: ->(raw) { ea.serialize(raw) } + ) end def define_schema @@ -111,6 +124,8 @@ def define_schema is_primary_key: true, is_read_only: true, is_sortable: true)) add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: true)) + add_field('receiving_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, is_read_only: false, is_sortable: true)) add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, @@ -157,6 +172,11 @@ def define_relations 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 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..89a290a1b --- /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,32 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # DirectDebitMandate.external_account ManyToOne. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToDirectDebitMandates, + # {} + # ) + class LinkExternalAccountToDirectDebitMandates < ForestAdminDatasourceCustomizer::Plugins::Plugin + EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze + DIRECT_DEBIT_MANDATE = 'MambuDirectDebitMandate'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkExternalAccountToDirectDebitMandates must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation('direct_debit_mandates', DIRECT_DEBIT_MANDATE, + origin_key: 'external_account_id', + 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_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..8cc13df9c --- /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,32 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # IncomingPayment.external_account ManyToOne. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToIncomingPayments, + # {} + # ) + class LinkExternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin + EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkExternalAccountToIncomingPayments must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, + origin_key: 'external_account_id', + 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_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..bb60e5e4e --- /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,32 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuExternalAccount for the native + # PaymentOrder.external_account ManyToOne (FK: receiving_account_id). + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToPaymentOrders, + # {} + # ) + class LinkExternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plugins::Plugin + EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkExternalAccountToPaymentOrders must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation('payment_orders', PAYMENT_ORDER, + origin_key: 'receiving_account_id', + origin_key_target: 'id') + end + end + 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 index 45ced6152..d0ed0680b 100644 --- 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 @@ -9,13 +9,16 @@ module ForestAdminDatasourceMambuPayments 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', @@ -27,13 +30,15 @@ module ForestAdminDatasourceMambuPayments 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', 'type', 'direction', 'status', 'amount', + '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', @@ -49,6 +54,17 @@ module ForestAdminDatasourceMambuPayments 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') @@ -106,6 +122,25 @@ module ForestAdminDatasourceMambuPayments .with(hash_including('connected_account_id' => 'acc1', page: 1)) 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) @@ -122,7 +157,8 @@ module ForestAdminDatasourceMambuPayments 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') + 'reconciliation_status', 'reconciled_amount', + 'receiving_account_id') { 'id' => 'po1', 'connected_account_id' => 'acc1', 'amount' => 1000 } end @@ -130,7 +166,8 @@ module ForestAdminDatasourceMambuPayments 'id' => 'ignored', 'status' => 'sent', 'created_at' => 't', 'value_date' => 't', 'initiated_at' => 't', 'reconciliation_status' => 'r', - 'reconciled_amount' => 0, 'amount' => 1000) + 'reconciled_amount' => 0, 'amount' => 1000, + 'receiving_account_id' => 'ea-ignored') expect(client).to have_received(:create_payment_order) 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 From 44ed5d7773f8223cd70dbc643ca3bff5254cc20a Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Fri, 22 May 2026 18:15:12 +0200 Subject: [PATCH 16/24] feat(mambu_payments): add internal-account relation plugins Adds OneToMany navigation from MambuInternalAccount to IncomingPayments (direct FK), PaymentOrders and Balances (transitive via the connected_account_ids array, resolved by a new TwoStepConnectedAccountFilter helper that rewrites EQUAL/IN filters into connected_account_id IN (...)). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../link_internal_account_to_balances.rb | 52 ++++++++ ...k_internal_account_to_incoming_payments.rb | 32 +++++ ...link_internal_account_to_payment_orders.rb | 52 ++++++++ .../two_step_connected_account_filter.rb | 56 +++++++++ .../link_internal_account_to_balances_spec.rb | 69 +++++++++++ ...ernal_account_to_incoming_payments_spec.rb | 30 +++++ ...internal_account_to_payment_orders_spec.rb | 112 ++++++++++++++++++ .../plugins/support.rb | 9 +- 8 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_connected_account_filter.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders_spec.rb 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..2a9c5e7ca --- /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,52 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable InternalAccount <-> Balance link. + # The chain is transitive: Balance.connected_account_id is matched + # against the InternalAccount.connected_account_ids array. + # See TwoStepConnectedAccountFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToBalances, + # {} + # ) + class LinkInternalAccountToBalances < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + BALANCE = 'MambuBalance'.freeze + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + FK_NAME = 'internal_account_id'.freeze + LOCAL_FK = 'connected_account_id'.freeze + ONE_TO_MANY_NAME = 'balances'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkInternalAccountToBalances must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(BALANCE) do |c| + # Virtual column: Balance has no native internal_account_id. + # The value is nil per record (reverse lookup would require scanning + # all internal accounts) — only EQUAL/IN are rewritten via the + # TwoStepConnectedAccountFilter below. + c.add_field(FK_NAME, ComputedDefinition.new( + column_type: 'String', + dependencies: ['id'], + values: proc { |records, _ctx| records.map { nil } } + )) + TwoStepConnectedAccountFilter.install(c, target_field: LOCAL_FK) + end + + datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, BALANCE, + 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_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..ead1708d6 --- /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,32 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Reciprocal OneToMany on MambuInternalAccount for the native + # IncomingPayment.internal_account ManyToOne. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToIncomingPayments, + # {} + # ) + class LinkInternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkInternalAccountToIncomingPayments must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, + origin_key: 'internal_account_id', + 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_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..3fba2f984 --- /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,52 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable InternalAccount <-> PaymentOrder link. + # The chain is transitive: PO.connected_account_id is matched against + # the InternalAccount.connected_account_ids array. + # See TwoStepConnectedAccountFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToPaymentOrders, + # {} + # ) + class LinkInternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + FK_NAME = 'internal_account_id'.freeze + LOCAL_FK = 'connected_account_id'.freeze + ONE_TO_MANY_NAME = 'payment_orders'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkInternalAccountToPaymentOrders must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| + # Virtual column: PaymentOrder has no native internal_account_id. + # The value is nil per record (reverse lookup would require scanning + # all internal accounts) — only EQUAL/IN are rewritten via the + # TwoStepConnectedAccountFilter below. + c.add_field(FK_NAME, ComputedDefinition.new( + column_type: 'String', + dependencies: ['id'], + values: proc { |records, _ctx| records.map { nil } } + )) + TwoStepConnectedAccountFilter.install(c, target_field: LOCAL_FK) + end + + datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, PAYMENT_ORDER, + 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/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..77e468da7 --- /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,56 @@ +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). Only EQUAL/IN are handled (the + # operators Forest's OneToMany navigation actually uses). + module TwoStepConnectedAccountFilter + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Projection = ForestAdminDatasourceToolkit::Components::Query::Projection + + INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze + ARRAY_FIELD = 'connected_account_ids'.freeze + FK_NAME = 'internal_account_id'.freeze + + # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. + NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze + + SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze + + def self.install(collection_customizer, target_field:) + SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(FK_NAME, operator) do |value, context| + ia_ids = TwoStepConnectedAccountFilter.normalize(value, operator) + next TwoStepConnectedAccountFilter.no_match(target_field) if ia_ids.empty? + + ca_ids = context.datasource.get_collection(INTERNAL_ACCOUNT).list( + Filter.new(condition_tree: ConditionTreeLeaf.new('id', Operators::IN, ia_ids)), + Projection.new([ARRAY_FIELD]) + ).flat_map { |r| Array(r[ARRAY_FIELD]) }.compact.uniq + + next TwoStepConnectedAccountFilter.no_match(target_field) if ca_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, ca_ids) + end + end + end + + def self.normalize(value, operator) + values = operator == Operators::IN ? Array(value) : [value] + values.compact.reject { |v| v.to_s.empty? }.uniq + end + + def self.no_match(target_field) + ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) + end + end + 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/support.rb b/packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/support.rb index 7393823fe..09f4e7e8a 100644 --- 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 @@ -38,10 +38,12 @@ def add_action(name, action) # used by the relation plugin specs. Mirrors the public DSL shape exposed # by ForestAdminDatasourceCustomizer::CollectionCustomizer. class FakeRelationCollection - attr_reader :imported_fields, :many_to_one_relations, :one_to_many_relations, :operator_handlers + 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 = {} @@ -52,6 +54,11 @@ def import_field(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 From ea57c2a39ee3ebfa7ed822c9156e533ca95ae5a7 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Fri, 22 May 2026 18:59:57 +0200 Subject: [PATCH 17/24] feat(mambu_payments): add payment-order relation plugins Adds OneToMany/ManyToOne navigation from MambuPaymentOrder to AccountHolder (via ExternalAccount, named receiving_account_holder), Returns and Events (direct polymorphic FKs leveraging UUID uniqueness), and Transactions (transitive via MambuReconciliation, resolved by a new TwoStepReconciliationFilter helper). Event.api_filters is extended to expose related_object_id so the Events navigation can filter server-side. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/event.rb | 12 ++ .../relations/link_payment_order_to_events.rb | 37 ++++++ ...yment_order_to_receiving_account_holder.rb | 53 ++++++++ .../link_payment_order_to_returns.rb | 36 ++++++ .../link_payment_order_to_transactions.rb | 55 +++++++++ .../two_step_reconciliation_filter.rb | 61 ++++++++++ .../collections/event_spec.rb | 22 ++++ .../link_payment_order_to_events_spec.rb | 30 +++++ ..._order_to_receiving_account_holder_spec.rb | 94 ++++++++++++++ .../link_payment_order_to_returns_spec.rb | 30 +++++ ...link_payment_order_to_transactions_spec.rb | 115 ++++++++++++++++++ 11 files changed, 545 insertions(+) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_reconciliation_filter.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions_spec.rb 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 index 83d8a3c22..e7a3634ee 100644 --- 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 @@ -83,6 +83,18 @@ def fetch_records(_caller, filter) datasource.client.list_events(**params) end + # 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 api_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. Records are grouped # by their (translated) related_object_type so each target collection is 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..f59b449d7 --- /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,37 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuPaymentOrder for Event.related_object_id. + # Event.related_object_id is polymorphic (payment_order, transaction, + # incoming_payment, ...), but UUIDs are globally unique so filtering by + # id alone yields exactly the events about the given PO. + # + # Requires Event.api_filters to expose `related_object_id` — added in + # the Event collection itself. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToEvents, + # {} + # ) + class LinkPaymentOrderToEvents < ForestAdminDatasourceCustomizer::Plugins::Plugin + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + EVENT = 'MambuEvent'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkPaymentOrderToEvents must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| + c.add_one_to_many_relation('events', EVENT, + origin_key: 'related_object_id', + 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_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..b99f40a70 --- /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,53 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable PaymentOrder <-> AccountHolder link. + # The chain is transitive: PO.receiving_account_id -> ExternalAccount.account_holder_id. + # Named `receiving_account_holder` rather than `account_holder` to make it + # explicit that this is the counterparty (receiving) account's holder, + # not the holder of our own (internal) side of the order. + # See TwoStepHolderFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReceivingAccountHolder, + # {} + # ) + class LinkPaymentOrderToReceivingAccountHolder < ForestAdminDatasourceCustomizer::Plugins::Plugin + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze + ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze + FK_NAME = 'account_holder_id'.freeze + LOCAL_FK = 'receiving_account_id'.freeze + IMPORT_PATH = 'external_account:account_holder_id'.freeze + MANY_TO_ONE_NAME = 'receiving_account_holder'.freeze + ONE_TO_MANY_NAME = 'payment_orders'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkPaymentOrderToReceivingAccountHolder must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| + c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) + c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, + foreign_key: FK_NAME, + foreign_key_target: 'id') + TwoStepHolderFilter.install(c, + fk_name: FK_NAME, + local_fk: LOCAL_FK, + intermediate_collection: EXTERNAL_ACCOUNT) + end + + datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, PAYMENT_ORDER, + 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_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..0d7b68b80 --- /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,36 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuPaymentOrder for Return.related_payment_id. + # Return.related_payment_id is polymorphic (payment_order or + # incoming_payment), but UUIDs are globally unique so filtering by id + # alone yields exactly the returns belonging to the given PO. The same + # column can later be used to expose `returns` on MambuIncomingPayment + # without conflict. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReturns, + # {} + # ) + class LinkPaymentOrderToReturns < ForestAdminDatasourceCustomizer::Plugins::Plugin + PAYMENT_ORDER = 'MambuPaymentOrder'.freeze + RETURN_COLL = 'MambuReturn'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkPaymentOrderToReturns must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| + c.add_one_to_many_relation('returns', RETURN_COLL, + origin_key: 'related_payment_id', + 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_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..85b1beeb3 --- /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,55 @@ +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 = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkPaymentOrderToTransactions must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + 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/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..c923855fa --- /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,61 @@ +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. + # Only EQUAL/IN are handled (the operators Forest's OneToMany navigation + # actually uses). + module TwoStepReconciliationFilter + 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 + + RECONCILIATION = 'MambuReconciliation'.freeze + + # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. + NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze + + SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze + + def self.install(collection_customizer, fk_name:, payment_type:, target_field:) + SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + payment_ids = TwoStepReconciliationFilter.normalize(value, operator) + next TwoStepReconciliationFilter.no_match(target_field) if payment_ids.empty? + + condition = ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('payment_id', Operators::IN, payment_ids), + ConditionTreeLeaf.new('payment_type', Operators::EQUAL, + payment_type) + ]) + + tx_ids = context.datasource.get_collection(RECONCILIATION).list( + Filter.new(condition_tree: condition), + Projection.new(['transaction_id']) + ).filter_map { |r| r['transaction_id'] }.uniq + + next TwoStepReconciliationFilter.no_match(target_field) if tx_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, tx_ids) + end + end + end + + def self.normalize(value, operator) + values = operator == Operators::IN ? Array(value) : [value] + values.compact.reject { |v| v.to_s.empty? }.uniq + end + + def self.no_match(target_field) + ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) + end + 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 index fd317171f..e771fb3a3 100644 --- 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 @@ -99,6 +99,28 @@ module ForestAdminDatasourceMambuPayments 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]) 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 From 63cc59cb86960a05e2bcef8fc6d7cc16916a38cc Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Fri, 22 May 2026 19:00:13 +0200 Subject: [PATCH 18/24] feat(mambu_payments): add incoming-payment relation plugins Adds OneToMany navigation from MambuIncomingPayment to Transactions (reusing TwoStepReconciliationFilter with payment_type='incoming_payment'), Returns and Events (direct polymorphic FKs), and matched ExpectedPayments (transitive through two reconciliations sharing a transaction, resolved by a new TwoStepCrossReconciliationFilter helper parameterised by src/dst payment_type for future cross-payment pairings). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../link_incoming_payment_to_events.rb | 37 +++++++ ...k_incoming_payment_to_expected_payments.rb | 61 +++++++++++ .../link_incoming_payment_to_returns.rb | 36 ++++++ .../link_incoming_payment_to_transactions.rb | 56 ++++++++++ .../two_step_cross_reconciliation_filter.rb | 83 ++++++++++++++ .../link_incoming_payment_to_events_spec.rb | 30 +++++ ...oming_payment_to_expected_payments_spec.rb | 103 ++++++++++++++++++ .../link_incoming_payment_to_returns_spec.rb | 30 +++++ ...k_incoming_payment_to_transactions_spec.rb | 90 +++++++++++++++ 9 files changed, 526 insertions(+) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_cross_reconciliation_filter.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions_spec.rb 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..5c6b76944 --- /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,37 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuIncomingPayment for Event.related_object_id. + # Event.related_object_id is polymorphic (incoming_payment, payment_order, + # transaction, ...), but UUIDs are globally unique so filtering by id + # alone yields exactly the events about the given IP. + # + # Requires Event.api_filters to expose `related_object_id` — declared in + # the Event collection itself. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToEvents, + # {} + # ) + class LinkIncomingPaymentToEvents < ForestAdminDatasourceCustomizer::Plugins::Plugin + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + EVENT = 'MambuEvent'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkIncomingPaymentToEvents must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| + c.add_one_to_many_relation('events', EVENT, + origin_key: 'related_object_id', + 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_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..3b770b61d --- /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,61 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable IncomingPayment <-> ExpectedPayment link. + # The chain crosses MambuReconciliation twice via the shared transaction: + # IP -> Reconciliation(incoming_payment) -> Transaction + # -> Reconciliation(expected_payment) -> ExpectedPayment + # Named `matched_expected_payments` on the IP side to make the transitive + # (reconciliation-driven) nature explicit — it is not a native FK. + # See TwoStepCrossReconciliationFilter for the OneToMany filter rewrite. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToExpectedPayments, + # {} + # ) + class LinkIncomingPaymentToExpectedPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + EXPECTED_PAYMENT = 'MambuExpectedPayment'.freeze + FK_NAME = 'incoming_payment_id'.freeze + SRC_PAYMENT_TYPE = 'incoming_payment'.freeze + DST_PAYMENT_TYPE = 'expected_payment'.freeze + ONE_TO_MANY_NAME = 'matched_expected_payments'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkIncomingPaymentToExpectedPayments must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(EXPECTED_PAYMENT) do |c| + # Virtual column: ExpectedPayment has no native incoming_payment_id. + # The link goes through two reconciliations sharing a transaction; + # populating a per-record value would require scanning all + # reconciliations. Kept nil; only EQUAL/IN filters are rewritten + # via the TwoStepCrossReconciliationFilter below. + c.add_field(FK_NAME, ComputedDefinition.new( + column_type: 'String', + dependencies: ['id'], + values: proc { |records, _ctx| records.map { nil } } + )) + TwoStepCrossReconciliationFilter.install(c, + fk_name: FK_NAME, + src_payment_type: SRC_PAYMENT_TYPE, + dst_payment_type: DST_PAYMENT_TYPE, + target_field: 'id') + end + + datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| + c.add_one_to_many_relation(ONE_TO_MANY_NAME, EXPECTED_PAYMENT, + 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_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..38ddd6391 --- /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,36 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # OneToMany on MambuIncomingPayment for Return.related_payment_id. + # Return.related_payment_id is polymorphic (payment_order or + # incoming_payment), but UUIDs are globally unique so filtering by id + # alone yields exactly the returns belonging to the given IP. The same + # column is also exposed as `returns` on MambuPaymentOrder; the two + # relations are independent because the underlying ids are disjoint. + # + # Install at the datasource level: + # @agent.use( + # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToReturns, + # {} + # ) + class LinkIncomingPaymentToReturns < ForestAdminDatasourceCustomizer::Plugins::Plugin + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + RETURN_COLL = 'MambuReturn'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkIncomingPaymentToReturns must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| + c.add_one_to_many_relation('returns', RETURN_COLL, + origin_key: 'related_payment_id', + 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_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..88486de1f --- /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,56 @@ +module ForestAdminDatasourceMambuPayments + module Plugins + module Relations + # Exposes a navigable IncomingPayment <-> Transaction link. + # Transaction has no native incoming_payment_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::LinkIncomingPaymentToTransactions, + # {} + # ) + class LinkIncomingPaymentToTransactions < ForestAdminDatasourceCustomizer::Plugins::Plugin + ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + + INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze + TRANSACTION = 'MambuTransaction'.freeze + FK_NAME = 'incoming_payment_id'.freeze + PAYMENT_TYPE = 'incoming_payment'.freeze + ONE_TO_MANY_NAME = 'transactions'.freeze + + def run(datasource_customizer, _collection_customizer = nil, _options = {}) + unless datasource_customizer + raise ArgumentError, + 'LinkIncomingPaymentToTransactions must be installed at the datasource level ' \ + 'via @agent.use(plugin, {})' + end + + datasource_customizer.customize_collection(TRANSACTION) do |c| + # Virtual column: Transaction has no native incoming_payment_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(INCOMING_PAYMENT) 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/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..8764b5307 --- /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,83 @@ +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. + # Only EQUAL/IN are handled (the operators Forest's OneToMany navigation + # actually uses). + module TwoStepCrossReconciliationFilter + 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 + + RECONCILIATION = 'MambuReconciliation'.freeze + + # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. + NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze + + SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze + + def self.install(collection_customizer, fk_name:, src_payment_type:, dst_payment_type:, target_field:) + SUPPORTED_OPS.each do |operator| + collection_customizer.replace_field_operator(fk_name, operator) do |value, context| + src_ids = TwoStepCrossReconciliationFilter.normalize(value, operator) + next TwoStepCrossReconciliationFilter.no_match(target_field) if src_ids.empty? + + tx_ids = TwoStepCrossReconciliationFilter.resolve_transactions(context, src_ids, src_payment_type) + next TwoStepCrossReconciliationFilter.no_match(target_field) if tx_ids.empty? + + dst_ids = TwoStepCrossReconciliationFilter.resolve_payments(context, tx_ids, dst_payment_type) + next TwoStepCrossReconciliationFilter.no_match(target_field) if dst_ids.empty? + + ConditionTreeLeaf.new(target_field, Operators::IN, dst_ids) + end + end + end + + def self.resolve_transactions(context, src_ids, src_payment_type) + condition = ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('payment_id', Operators::IN, src_ids), + ConditionTreeLeaf.new('payment_type', Operators::EQUAL, + src_payment_type) + ]) + context.datasource.get_collection(RECONCILIATION).list( + Filter.new(condition_tree: condition), + Projection.new(['transaction_id']) + ).filter_map { |r| r['transaction_id'] }.uniq + end + + def self.resolve_payments(context, tx_ids, dst_payment_type) + condition = ConditionTreeBranch.new('And', [ + ConditionTreeLeaf.new('transaction_id', Operators::IN, tx_ids), + ConditionTreeLeaf.new('payment_type', Operators::EQUAL, + dst_payment_type) + ]) + context.datasource.get_collection(RECONCILIATION).list( + Filter.new(condition_tree: condition), + Projection.new(['payment_id']) + ).filter_map { |r| r['payment_id'] }.uniq + end + + def self.normalize(value, operator) + values = operator == Operators::IN ? Array(value) : [value] + values.compact.reject { |v| v.to_s.empty? }.uniq + end + + def self.no_match(target_field) + ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) + end + end + 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 From a1efb99a12425227185b1476682c974c167e1cb5 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 26 May 2026 15:12:45 +0200 Subject: [PATCH 19/24] chore(mambu_payments): drop freeze on VERSION constant Aligns the version.rb with the rest of the packages and adds the file to the Style/MutableConstant rubocop exclude list. Co-Authored-By: Claude Opus 4.7 --- .rubocop.yml | 1 + .../lib/forest_admin_datasource_mambu_payments/version.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 0b9a1fe57..7292621cf 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). 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 index f1eb2cde3..4bcb0ed6d 100644 --- 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 @@ -1,3 +1,3 @@ module ForestAdminDatasourceMambuPayments - VERSION = '0.1.0'.freeze + VERSION = '0.1.0' end From f0f56e92088d1f55f5e9e18694a5b93ad0d71a23 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 18:04:49 +0200 Subject: [PATCH 20/24] refactor(mambu_payments): fix filters/count/pagination Address review findings on the Mambu/Numeral datasource. Correctness: - Align advertised filter_operators with api_filters so the UI only offers filters the API can serve. - Add Plugins::DisableSearch (Numeral has no free-text search; emulated search becomes an OR the translator rejects). - Count via the server-side total instead of list().size, which capped counts at one page. - Paginate the two-step relation filters fully so large relations are no longer truncated at one page. - Handle pagination windows larger than one API page. - project returns an empty row when the projection has only relation paths (no leaking unrequested columns). - Surface Numeral's HTTP status and error body through APIError. - Drop expected_payment account snapshots in favour of the ManyToOne relations (single source of truth, like Transaction). Numeral list endpoints have no id/ids filter, so id-lookups stay on the per-id find endpoint and combined id predicates raise loudly instead of sending an ignored param. Dedup: - Shared read path and schema-derived build_payload in BaseCollection across 17 collections. - PivotResolution mixin for the 4 two-step filters. - Helpers.require_datasource! guard for the 16 relation plugins. Specs: 498 examples, 0 failures; rubocop clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../forest_admin_datasource_mambu_payments.rb | 14 +- .../client.rb | 61 +++++ .../client/reads.rb | 85 ++++--- .../collections/account_holder.rb | 48 +--- .../collections/balance.rb | 72 ++---- .../collections/base_collection.rb | 226 +++++++++++++++--- .../collections/claim.rb | 88 +++---- .../collections/connected_account.rb | 117 ++++----- .../collections/direct_debit_mandate.rb | 121 +++------- .../collections/event.rb | 116 +++------ .../collections/expected_payment.rb | 139 ++++------- .../collections/external_account.rb | 127 ++++------ .../collections/file.rb | 86 +++---- .../collections/incoming_payment.rb | 127 +++------- .../collections/internal_account.rb | 149 ++++-------- .../collections/payee_verification_request.rb | 75 ++---- .../collections/payment_capture.rb | 138 ++++------- .../collections/payment_order.rb | 126 +++------- .../collections/reconciliation.rb | 82 ++----- .../collections/return.rb | 131 ++++------ .../collections/transaction.rb | 122 +++------- .../plugins/disable_search.rb | 31 +++ .../plugins/helpers.rb | 12 + ...account_holder_to_direct_debit_mandates.rb | 6 +- ...ink_account_holder_to_incoming_payments.rb | 6 +- ...ternal_account_to_direct_debit_mandates.rb | 6 +- ...k_external_account_to_incoming_payments.rb | 6 +- ...link_external_account_to_payment_orders.rb | 6 +- .../link_incoming_payment_to_events.rb | 6 +- ...k_incoming_payment_to_expected_payments.rb | 6 +- .../link_incoming_payment_to_returns.rb | 6 +- .../link_incoming_payment_to_transactions.rb | 6 +- .../link_internal_account_to_balances.rb | 6 +- ...k_internal_account_to_incoming_payments.rb | 6 +- ...link_internal_account_to_payment_orders.rb | 6 +- .../relations/link_payment_order_to_events.rb | 6 +- ...yment_order_to_receiving_account_holder.rb | 6 +- .../link_payment_order_to_returns.rb | 6 +- .../link_payment_order_to_transactions.rb | 6 +- .../plugins/relations/pivot_resolution.rb | 71 ++++++ .../two_step_connected_account_filter.rb | 36 +-- .../two_step_cross_reconciliation_filter.rb | 72 ++---- .../relations/two_step_holder_filter.rb | 38 +-- .../two_step_reconciliation_filter.rb | 52 ++-- .../client_spec.rb | 48 ++++ .../collections/account_holder_spec.rb | 4 +- .../collections/balance_spec.rb | 4 +- .../collections/base_collection_spec.rb | 93 +++++-- .../collections/claim_spec.rb | 4 +- .../collections/connected_account_spec.rb | 4 +- .../collections/event_spec.rb | 4 +- .../collections/expected_payment_spec.rb | 32 +-- .../collections/file_spec.rb | 4 +- .../collections/incoming_payment_spec.rb | 4 +- .../payee_verification_request_spec.rb | 5 +- .../collections/payment_capture_spec.rb | 4 +- .../collections/reconciliation_spec.rb | 4 +- .../collections/return_spec.rb | 4 +- .../collections/transaction_spec.rb | 4 +- .../plugins/disable_search_spec.rb | 46 ++++ .../relations/pivot_resolution_spec.rb | 77 ++++++ 61 files changed, 1365 insertions(+), 1638 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/disable_search.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/disable_search_spec.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/spec/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution_spec.rb 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 index 01fc92d9c..29ff15784 100644 --- 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 @@ -12,7 +12,19 @@ module ForestAdminDatasourceMambuPayments class Error < StandardError; end class ConfigurationError < Error; end class UnsupportedOperatorError < Error; end - class APIError < 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 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 index a8a1f32e8..4712c8981 100644 --- 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 @@ -1,4 +1,5 @@ module ForestAdminDatasourceMambuPayments + # rubocop:disable Metrics/ClassLength class Client include Reads include Writes @@ -18,10 +19,22 @@ def list_resource(path, params = {}) end end + # Server-side count. Numeral list responses carry a `total` field, so we + # ask for a single record and read the total off the envelope rather than + # materializing (and capping at one page of) the whole collection. + def count_resource(path, params = {}) + must_succeed("count(#{path})") do + body = connection.get(path, normalize_params(params.merge(limit: 1))).body + extract_total(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 @@ -76,6 +89,14 @@ def extract_record(body) body end + # Reads the `total` count off a list envelope, falling back to the size of + # the returned records when the API omits it (e.g. an array body). + def extract_total(body, path) + return body['total'].to_i if body.is_a?(Hash) && body.key?('total') + + extract_records(body, path).size + end + def delete_resource(path, id) must_succeed("delete(#{path}/#{id})") do connection.delete("#{path}/#{id}") @@ -89,10 +110,49 @@ def normalize_params(params) 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'] + message ||= Array(parsed['errors']).filter_map do |e| + e.is_a?(Hash) ? (e['message'] || e['detail']) : e + end.join('; ') + (message.to_s.empty? ? parsed.to_json : message)[0, 500] + 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 @@ -117,4 +177,5 @@ def connection 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 index 188d05d54..b76f023aa 100644 --- 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 @@ -1,66 +1,83 @@ 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_connected_accounts(**params) = list_resource('connected_accounts', params) + def count_connected_accounts(**params) = count_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_payment_orders(**params) = list_resource('payment_orders', params) + def count_payment_orders(**params) = count_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_transactions(**params) = list_resource('transactions', params) + def count_transactions(**params) = count_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_balances(**params) = list_resource('balances', params) + def count_balances(**params) = count_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_account_holders(**params) = list_resource('account_holders', params) + def count_account_holders(**params) = count_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_external_accounts(**params) = list_resource('external_accounts', params) + def count_external_accounts(**params) = count_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_internal_accounts(**params) = list_resource('internal_accounts', params) + def count_internal_accounts(**params) = count_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_incoming_payments(**params) = list_resource('incoming_payments', params) + def count_incoming_payments(**params) = count_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_direct_debit_mandates(**params) = list_resource('direct_debit_mandates', params) + def count_direct_debit_mandates(**params) = count_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_expected_payments(**params) = list_resource('expected_payments', params) + def count_expected_payments(**params) = count_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_events(**params) = list_resource('events', params) + def count_events(**params) = count_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_files(**params) = list_resource('files', params) + def count_files(**params) = count_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) + def list_returns(**params) = list_resource('returns', params) + def count_returns(**params) = count_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_claims(**params) = list_resource('claims', params) + def count_claims(**params) = count_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) + def list_reconciliations(**params) = list_resource('reconciliations', params) + def count_reconciliations(**params) = count_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) + def list_payment_captures(**params) = list_resource('payment_captures', params) + def count_payment_captures(**params) = count_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) + def list_payee_verification_requests(**params) = list_resource('payee_verification_requests', params) + def count_payee_verification_requests(**params) = count_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/collections/account_holder.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb index 02de89d78..452cc9e8f 100644 --- 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 @@ -3,18 +3,16 @@ 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - records.map { |r| project(serialize(r), projection) } - end - def create(_caller, data) serialize(datasource.client.create_account_holder(build_payload(data))) end @@ -40,42 +38,16 @@ def serialize(record) } end - protected - - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - private - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_account_holder(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_account_holders(**params) - end - - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object created_at disabled_at].each { |k| attrs.delete(k) } - attrs - end - def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 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 index 8d4baf615..42f6b23b6 100644 --- 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 @@ -5,20 +5,16 @@ class Balance < BaseCollection ENUM_DIRECTION = %w[debit credit].freeze + client_resource :balance + def initialize(datasource) super(datasource, 'MambuBalance') define_schema define_relations + reconcile_filter_operators! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -37,59 +33,35 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_balance(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_balances(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], + 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('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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('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 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 index 8edea9f92..6c661df2b 100644 --- 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 @@ -1,9 +1,23 @@ module ForestAdminDatasourceMambuPayments module Collections + # Shared behaviour for every Numeral-backed collection. + # + # Subclasses declare their REST resource once with `client_resource` and + # implement `serialize`. The read path (list / count / id-lookup / + # pagination / relation embedding) lives here so a fix lands in one place + # rather than being copy-pasted across ~17 collections. + # + # Filtering contract: `collection_filters` lists the server-filterable + # fields (merged with the always-present `id`). `reconcile_filter_operators!` + # then narrows each column's advertised `filter_operators` to exactly what + # the Numeral API can serve, so the UI never offers a filter that would + # raise at query time. + # 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 + 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 @@ -13,19 +27,136 @@ class BaseCollection < ForestAdminDatasourceToolkit::Collection BOOL_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::PRESENT, Operators::BLANK].freeze - def aggregate(caller, filter, aggregation, _limit = nil) + # 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 backing this collection, wiring the + # generic read path to the matching `list_*` / `count_*` / `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 + + def aggregate(_caller, filter, aggregation, _limit = nil) unless aggregation.operation == 'Count' && aggregation.field.nil? && aggregation.groups.empty? - raise ForestAdminDatasourceToolkit::Exceptions::ForestException, - 'Mambu Payments datasource only supports Count aggregation without groups.' + raise ForestException, 'Mambu Payments datasource only supports Count aggregation without groups.' end - [{ 'value' => aggregate_count(caller, filter), 'group' => {} }] + [{ 'value' => count_records(filter), 'group' => {} }] end protected - def aggregate_count(_caller, _filter) - raise NotImplementedError, "#{self.class} did not implement aggregate_count" + # 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 + + fetch_page(filter.page, translate_filters(filter.condition_tree)) + end + + # Resolves a set of ids via the per-id `find_*` endpoint. Numeral has no + # `id`/`ids` list filter, so there is no batch fetch — we de-duplicate and + # fetch each distinct id once (one `GET /resource/:id` per id). + 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 + + def fetch_page(page, params) + return fetch_window(page, params) if page&.limit && page.limit > Client::MAX_PER_PAGE + + page_num, per_page = translate_page(page) + client_list(**params, page: page_num, limit: per_page) + end + + # Fetches a window larger than one API page by walking successive pages of + # MAX_PER_PAGE and slicing to the requested [offset, offset + limit) range. + def fetch_window(page, params) + offset = page.offset.to_i + limit = page.limit.to_i + start = offset % Client::MAX_PER_PAGE + page_num = (offset / Client::MAX_PER_PAGE) + 1 + collected = [] + loop do + batch = client_list(**params, page: page_num, limit: Client::MAX_PER_PAGE) + collected.concat(batch) + break if batch.size < Client::MAX_PER_PAGE || collected.size >= start + limit + + page_num += 1 + end + collected[start, limit] || [] + end + + def count_records(filter) + ids = extract_id_lookup(filter.condition_tree) + return fetch_by_ids(ids).size if ids + + client_count(**translate_filters(filter.condition_tree)) + end + + def client_list(**params) + datasource.client.public_send("list_#{self.class.resource_plural}", **params) + end + + def client_count(**params) + datasource.client.public_send("count_#{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) @@ -37,15 +168,6 @@ def extract_id_lookup(node) end end - # Server-filterable fields the Numeral API accepts for this collection. - # Subclasses override with entries like: - # { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } - # Anything not declared here raises UnsupportedOperatorError when filtered - # on, so we never silently return unfiltered results. - def api_filters - {} - end - def translate_filters(condition_tree) Query::ConditionTreeTranslator.call(condition_tree, api_filters: api_filters) end @@ -53,9 +175,11 @@ def translate_filters(condition_tree) 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?(':') } - return record if wanted.empty? - wanted.to_h { |k| [k, record[k]] } end @@ -68,6 +192,11 @@ def translate_page(page) 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 @@ -81,29 +210,56 @@ def relations_in(projection) Array(projection).map(&:to_s).filter_map { |p| p.split(':').first if p.include?(':') }.uniq end - # Bulk-fetches records for a ManyToOne relation and writes the serialized - # related record back onto each row. The customizer's relation decorator - # only handles emulated relations, so native datasource relations (like - # ours) must populate the sub-record themselves. - # - # Expected opts keys: :foreign_key, :relation_name, :fetcher, :serializer. - def embed_many_to_one(rows, sources, projection, **opts) - relation_name = opts.fetch(:relation_name) - return if projection.nil? || !relations_in(projection).include?(relation_name) + # 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? - foreign_key = opts.fetch(:foreign_key) - ids = sources.filter_map { |s| s[foreign_key] }.reject { |id| id.nil? || id.to_s.empty? }.uniq + sources = records.map { |r| attrs_of(r) } + many_to_one_embeds.each do |embed| + target = datasource.get_collection(embed[:collection]) + embed_many_to_one( + rows, sources, projection, + foreign_key: embed[:foreign_key], relation_name: embed[:relation_name], + batch_fetcher: ->(ids) { target.send(:fetch_by_ids, ids) }, + serializer: ->(raw) { target.serialize(raw) } + ) + end + end + + # Bulk-fetches the related records for a ManyToOne relation in a single + # batched call and writes the serialized record back onto each row. + # rubocop:disable Metrics/ParameterLists + def embed_many_to_one(rows, sources, projection, foreign_key:, relation_name:, batch_fetcher:, serializer:) + return unless relations_in(projection).include?(relation_name) + + ids = sources.filter_map { |s| s[foreign_key] }.reject { |id| id.to_s.empty? }.uniq return if ids.empty? - cache = ids.to_h { |id| [id, opts.fetch(:fetcher).call(id)] }.compact + by_id = batch_fetcher.call(ids).to_h { |raw| [attrs_of(raw)['id'], raw] } rows.each_with_index do |row, i| fk_value = sources[i][foreign_key] - next if fk_value.nil? || fk_value.to_s.empty? + next if fk_value.to_s.empty? - raw = cache[fk_value] - row[relation_name] = raw && opts.fetch(:serializer).call(raw) + raw = by_id[fk_value] + row[relation_name] = raw && serializer.call(raw) end end + # rubocop:enable Metrics/ParameterLists + + # 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 index 31e772424..1caceccfb 100644 --- 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 @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class Claim < BaseCollection @@ -8,20 +7,16 @@ class Claim < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -44,22 +39,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_claim(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_claims(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'related_payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -68,54 +48,43 @@ def api_filters } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_STATUS, is_read_only: true, is_sortable: true)) - add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reason', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_RELATED_PAYMENT, + 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', filter_operators: STRING_OPS, + 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', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 @@ -128,4 +97,3 @@ def define_relations end end end -# rubocop:enable Metrics/ClassLength 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 index 0050114cf..38117ad98 100644 --- 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 @@ -1,21 +1,19 @@ -# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +# 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - records.map { |r| project(serialize(r), projection) } - end - def serialize(record) a = attrs_of(record) { @@ -49,80 +47,47 @@ def serialize(record) } end - protected - - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - private - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_connected_account(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_connected_accounts(**params) - end - def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('distinguished_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('settlement_account', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('legal_entity_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('receiving_agent', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('services_activated', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('file_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: true, is_sortable: false)) - add_field('return_auto_approval', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: true, is_sortable: false)) + 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', filter_operators: BOOL_OPS, - is_read_only: true, is_sortable: false)) - add_field('address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 @@ -136,4 +101,4 @@ def define_relations end end end -# rubocop:enable Metrics/ClassLength, Metrics/MethodLength +# 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 index 323fec9d8..a71fea75b 100644 --- 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 @@ -7,20 +7,16 @@ class DirectDebitMandate < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_direct_debit_mandate(build_payload(data))) end @@ -62,94 +58,54 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_direct_debit_mandate(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_direct_debit_mandates(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'external_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object status created_at].each { |k| attrs.delete(k) } - attrs + 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 - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - ea = datasource.get_collection('MambuExternalAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'external_account_id', relation_name: 'external_account', - fetcher: ->(id) { datasource.client.find_external_account(id) }, - serializer: ->(raw) { ea.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('scheme', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_SCHEME, is_read_only: false, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('sequence_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_SEQUENCE_TYPE, + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('unique_mandate_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('signature_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('signature_location', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('creditor', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('debtor', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('debtor_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('amendment_information', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, 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 @@ -167,5 +123,4 @@ def define_relations 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 index e7a3634ee..463d96e36 100644 --- 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 @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class Event < BaseCollection @@ -22,33 +21,16 @@ class Event < BaseCollection ENUM_STATUS = %w[created delivered pending_retry failed archived].freeze - FETCHERS = { - 'MambuPaymentOrder' => :find_payment_order, - 'MambuTransaction' => :find_transaction, - 'MambuIncomingPayment' => :find_incoming_payment, - 'MambuExpectedPayment' => :find_expected_payment, - 'MambuDirectDebitMandate' => :find_direct_debit_mandate, - 'MambuBalance' => :find_balance, - 'MambuConnectedAccount' => :find_connected_account, - 'MambuAccountHolder' => :find_account_holder, - 'MambuInternalAccount' => :find_internal_account, - 'MambuExternalAccount' => :find_external_account - }.freeze + client_resource :event def initialize(datasource) super(datasource, 'MambuEvent') define_schema define_relations + reconcile_filter_operators! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -68,97 +50,75 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_event(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_events(**params) - end - # 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 api_filters + 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. Records are grouped - # by their (translated) related_object_type so each target collection is - # queried in a single batched pass. + # `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) } - grouped = group_by_collection(sources) - - caches = grouped.transform_values do |entries| - collection_name = entries.first[:collection_name] - fetcher = FETCHERS[collection_name] - serializer = datasource.get_collection(collection_name) - ids = entries.map { |e| e[:id] }.uniq - ids.to_h { |id| [id, datasource.client.public_send(fetcher, id)] } - .compact - .transform_values { |raw| serializer.serialize(raw) } - end + 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.nil? || id.to_s.empty? + next if type.nil? || id.to_s.empty? row['related_object'] = caches.dig(type, id) end end - def group_by_collection(sources) - sources.each_with_object({}) do |src, acc| + 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.nil? || id.to_s.empty? + next if type.nil? || id.to_s.empty? + + ids_by_collection[type] << id + end - (acc[type] ||= []) << { collection_name: type, id: id } + ids_by_collection.to_h do |collection_name, ids| + target = datasource.get_collection(collection_name) + by_id = target.send(: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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('topic', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('related_object_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('related_object_type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_STATUS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('webhook_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 @@ -172,5 +132,3 @@ def define_relations end end end - -# rubocop:enable Metrics/ClassLength 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 index ee648c009..9ee9e7472 100644 --- 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 @@ -1,4 +1,4 @@ -# rubocop:disable Metrics/ClassLength, Metrics/MethodLength +# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class ExpectedPayment < BaseCollection @@ -6,20 +6,16 @@ class ExpectedPayment < BaseCollection ENUM_DIRECTION = %w[debit credit].freeze + client_resource :expected_payment + def initialize(datasource) super(datasource, 'MambuExpectedPayment') define_schema define_relations + reconcile_filter_operators! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_expected_payment(build_payload(data))) end @@ -49,8 +45,6 @@ def serialize(record) 'start_date' => a['start_date'], 'end_date' => a['end_date'], 'descriptions' => a['descriptions'], - 'internal_account_snapshot' => a['internal_account'], - 'external_account_snapshot' => a['external_account'], 'reconciliation_status' => a['reconciliation_status'], 'reconciled_amount' => a['reconciled_amount'], 'custom_fields' => a['custom_fields'], @@ -63,22 +57,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_expected_payment(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_expected_payments(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -86,84 +65,49 @@ def api_filters } end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object reconciliation_status reconciled_amount created_at updated_at canceled_at - internal_account_snapshot external_account_snapshot].each { |k| attrs.delete(k) } - attrs + # 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 - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - ia = datasource.get_collection('MambuInternalAccount') - ea = datasource.get_collection('MambuExternalAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'internal_account_id', relation_name: 'internal_account', - fetcher: ->(id) { datasource.client.find_internal_account(id) }, - serializer: ->(raw) { ia.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'external_account_id', relation_name: 'external_account', - fetcher: ->(id) { datasource.client.find_external_account(id) }, - serializer: ->(raw) { ea.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, + 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', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('amount_to', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('start_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('end_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('descriptions', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('internal_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('external_account_snapshot', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, 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 @@ -186,5 +130,4 @@ def define_relations end end end - -# rubocop:enable Metrics/ClassLength, Metrics/MethodLength +# 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 index 5ca785e3c..8980a4592 100644 --- 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 @@ -4,20 +4,16 @@ 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_external_account(build_payload(data))) end @@ -63,94 +59,54 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_external_account(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_external_accounts(**params) - end - - def api_filters + def collection_filters { 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } } end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object status status_details created_at disabled_at account_verification].each { |k| attrs.delete(k) } - attrs + def many_to_one_embeds + [ + { foreign_key: 'account_holder_id', relation_name: 'account_holder', + collection: 'MambuAccountHolder' } + ] end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ah = datasource.get_collection('MambuAccountHolder') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'account_holder_id', relation_name: 'account_holder', - fetcher: ->(id) { datasource.client.find_account_holder(id) }, - serializer: ->(raw) { ah.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('holder_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('holder_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('account_holder_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('organization_identification', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('company_registration_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('account_verification', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('disabled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 @@ -163,5 +119,4 @@ def define_relations 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 index 06c29aadb..be97daeb8 100644 --- 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 @@ -6,20 +6,16 @@ class File < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -42,72 +38,44 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_file(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_files(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] } } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('filename', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('size', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: true)) - add_field('summary', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_STATUS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 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 index e35ba2879..b998ab6d5 100644 --- 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 @@ -4,20 +4,16 @@ 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -49,22 +45,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_incoming_payment(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_incoming_payments(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -72,78 +53,49 @@ def api_filters } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - ia = datasource.get_collection('MambuInternalAccount') - ea = datasource.get_collection('MambuExternalAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'internal_account_id', relation_name: 'internal_account', - fetcher: ->(id) { datasource.client.find_internal_account(id) }, - serializer: ->(raw) { ia.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'external_account_id', relation_name: 'external_account', - fetcher: ->(id) { datasource.client.find_external_account(id) }, - serializer: ->(raw) { ea.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('uetr', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('structured_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('return_information', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 @@ -166,5 +118,4 @@ def define_relations 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 index cce1fc4d3..58e2e4331 100644 --- 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 @@ -4,20 +4,16 @@ 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_internal_account(build_payload(data))) end @@ -69,105 +65,63 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_internal_account(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_internal_accounts(**params) - end - - def api_filters + def collection_filters { 'account_holder_id' => { ops: [Operators::EQUAL, Operators::IN] } } end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object status status_details created_at bank_data].each { |k| attrs.delete(k) } - attrs + def many_to_one_embeds + [ + { foreign_key: 'account_holder_id', relation_name: 'account_holder', + collection: 'MambuAccountHolder' } + ] end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ah = datasource.get_collection('MambuAccountHolder') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'account_holder_id', relation_name: 'account_holder', - fetcher: ->(id) { datasource.client.find_account_holder(id) }, - serializer: ->(raw) { ah.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('holder_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('alternative_holder_names', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('connected_account_ids', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('account_number', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('account_number_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_code', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('bank_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('bank_code_format', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('holder_address', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('account_holder_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('creditor_identifier', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('organization_identification', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('customer_bic', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('distinguished_name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('currencies', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('cbs_source', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('cbs_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('cbs_account_type', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('synchronized_with_bank', ColumnSchema.new(column_type: 'Boolean', filter_operators: BOOL_OPS, - is_read_only: false, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 @@ -180,5 +134,4 @@ def define_relations 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 index 859ca1a73..1eb68b8e0 100644 --- 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 @@ -13,16 +13,15 @@ class PayeeVerificationRequest < BaseCollection 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! enable_count end - def list(caller, filter, projection) - fetch_records(caller, filter).map { |r| project(serialize(r), projection) } - end - def serialize(record) a = attrs_of(record) { @@ -46,22 +45,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_payee_verification_request(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_payee_verification_requests(**params) - end - - def api_filters + def collection_filters { 'status' => { ops: [Operators::EQUAL, Operators::IN] }, 'direction' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -70,44 +54,35 @@ def api_filters } end + private + def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_STATUS, is_read_only: true, is_sortable: true)) - add_field('failure_code', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_FAILURE_CODE, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_SCHEME, 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', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('matching_result', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_MATCHING_RESULT, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('matching_details', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('scheme_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('response_received_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 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 index 12bd38388..fa3761373 100644 --- 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 @@ -8,20 +8,16 @@ class PaymentCapture < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -60,22 +56,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_payment_capture(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_payment_captures(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'type' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -84,84 +65,63 @@ def api_filters } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) - add_field('source', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_SOURCE, is_read_only: true, is_sortable: true)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('original_payment_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + add_field('type', ColumnSchema.new(column_type: 'Enum', enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) - add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('remittance_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('remittance_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('transaction_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('authorization_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('payment_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('network', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('merchant_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('fee_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('fee_amount_currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('net_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('net_amount_currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reconciliation_status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + 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', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('cbs_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('lending', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 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 index 2b845b3ce..fc7c89202 100644 --- 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 @@ -6,20 +6,16 @@ class PaymentOrder < BaseCollection ENUM_DIRECTION = %w[debit credit].freeze + client_resource :payment_order + def initialize(datasource) super(datasource, 'MambuPaymentOrder') define_schema define_relations + reconcile_filter_operators! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_payment_order(build_payload(data))) end @@ -63,26 +59,11 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_payment_order(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_payment_orders(**params) - end - # 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 api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, # Numeral's list endpoint exposes the receiving external account @@ -92,78 +73,46 @@ def api_filters } end - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id status created_at value_date initiated_at reconciliation_status reconciled_amount - receiving_account_id].each do |k| - attrs.delete(k) - end - attrs + 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 - def embed_relations(rows, records, projection) - ca = datasource.get_collection('MambuConnectedAccount') - ea = datasource.get_collection('MambuExternalAccount') - sources = records.map { |r| attrs_of(r) } - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'receiving_account_id', relation_name: 'external_account', - fetcher: ->(id) { datasource.client.find_external_account(id) }, - serializer: ->(raw) { ea.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, is_read_only: false, is_sortable: false)) - add_field('status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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('purpose', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('end_to_end_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('idempotency_key', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('requested_execution_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: false, is_sortable: true)) - add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('initiated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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: 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 @@ -181,5 +130,4 @@ def define_relations 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 index 2ba46b61b..324ce696f 100644 --- 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 @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class Reconciliation < BaseCollection @@ -7,20 +6,16 @@ class Reconciliation < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_reconciliation(build_payload(data))) end @@ -48,22 +43,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_reconciliation(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_reconciliations(**params) - end - - def api_filters + def collection_filters { 'transaction_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -72,55 +52,34 @@ def api_filters } end - # Numeral's create payload is narrow (transaction_id required, payment_id / - # amount / metadata optional). Update only accepts metadata. We blacklist - # the system-managed fields so the same helper can serve both calls. - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[id object match_type canceled_at created_at].each { |k| attrs.delete(k) } - attrs + def many_to_one_embeds + [ + { foreign_key: 'transaction_id', relation_name: 'transaction', collection: 'MambuTransaction' } + ] end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - tx = datasource.get_collection('MambuTransaction') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'transaction_id', relation_name: 'transaction', - fetcher: ->(id) { datasource.client.find_transaction(id) }, - serializer: ->(raw) { tx.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: true)) + 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', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('payment_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_PAYMENT_TYPE, + 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', filter_operators: NUMBER_OPS, - is_read_only: false, is_sortable: false)) - add_field('match_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_MATCH_TYPE, - is_read_only: true, is_sortable: true)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('canceled_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + 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 @@ -133,4 +92,3 @@ def define_relations end end end -# rubocop:enable Metrics/ClassLength 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 index 0e4b000a7..8f0003f71 100644 --- 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 @@ -10,20 +10,16 @@ class Return < BaseCollection 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! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def create(_caller, data) serialize(datasource.client.create_return(build_payload(data))) end @@ -65,22 +61,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_return(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_returns(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'related_payment_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -89,86 +70,54 @@ def api_filters } end - # The Numeral create payload is narrow (related_payment_id, return_reason, - # related_payment_suspended, metadata); update is even narrower (status + - # status_details, OR metadata). We blacklist system-managed/read-only - # fields rather than whitelisting so a future writable field doesn't get - # silently swallowed if we forget to update this list. - def build_payload(data) - attrs = data.transform_keys(&:to_s) - %w[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].each do |k| - attrs.delete(k) - end - attrs + def many_to_one_embeds + [ + { foreign_key: 'connected_account_id', relation_name: 'connected_account', + collection: 'MambuConnectedAccount' } + ] end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - end + private def define_schema - add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_RELATED_PAYMENT, + 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', filter_operators: BOOL_OPS, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_RETURN_TYPE, + 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', filter_operators: STRING_OPS, - enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) - add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_STATUS, is_read_only: false, is_sortable: true)) - add_field('status_details', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('return_reason', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: false, is_sortable: false)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('originating_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('receiving_account', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('aggregation_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('file_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('metadata', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: false, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - 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 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 index 3e31167a4..e51a45bf8 100644 --- 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 @@ -1,4 +1,3 @@ -# rubocop:disable Metrics/ClassLength module ForestAdminDatasourceMambuPayments module Collections class Transaction < BaseCollection @@ -6,20 +5,16 @@ class Transaction < BaseCollection ENUM_DIRECTION = %w[debit credit].freeze + client_resource :transaction + def initialize(datasource) super(datasource, 'MambuTransaction') define_schema define_relations + reconcile_filter_operators! enable_count end - def list(caller, filter, projection) - records = fetch_records(caller, filter) - rows = records.map { |r| project(serialize(r), projection) } - embed_relations(rows, records, projection) - rows - end - def serialize(record) a = attrs_of(record) { @@ -47,22 +42,7 @@ def serialize(record) protected - def aggregate_count(caller, filter) - list(caller, filter, ['id']).size - end - - private - - def fetch_records(_caller, filter) - ids = extract_id_lookup(filter.condition_tree) - return ids.filter_map { |id| datasource.client.find_transaction(id) } if ids - - page, per_page = translate_page(filter.page) - params = translate_filters(filter.condition_tree).merge(page: page, limit: per_page) - datasource.client.list_transactions(**params) - end - - def api_filters + def collection_filters { 'connected_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, 'internal_account_id' => { ops: [Operators::EQUAL, Operators::IN] }, @@ -70,70 +50,46 @@ def api_filters } end - def embed_relations(rows, records, projection) - sources = records.map { |r| attrs_of(r) } - ca = datasource.get_collection('MambuConnectedAccount') - ia = datasource.get_collection('MambuInternalAccount') - ea = datasource.get_collection('MambuExternalAccount') - embed_many_to_one( - rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: ->(id) { datasource.client.find_connected_account(id) }, - serializer: ->(raw) { ca.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'internal_account_id', relation_name: 'internal_account', - fetcher: ->(id) { datasource.client.find_internal_account(id) }, - serializer: ->(raw) { ia.serialize(raw) } - ) - embed_many_to_one( - rows, sources, projection, - foreign_key: 'external_account_id', relation_name: 'external_account', - fetcher: ->(id) { datasource.client.find_external_account(id) }, - serializer: ->(raw) { ea.serialize(raw) } - ) + 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', filter_operators: STRING_OPS, - is_primary_key: true, is_read_only: true, is_sortable: true)) - add_field('object', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('connected_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('direction', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, - enum_values: ENUM_DIRECTION, is_read_only: true, is_sortable: false)) - add_field('amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('currency', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('structured_reference', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('value_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('booking_date', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) - add_field('internal_account_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, + 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', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: false)) - add_field('bank_data', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('reconciliation_status', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, - is_read_only: true, is_sortable: true)) - add_field('reconciled_amount', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, - is_read_only: true, is_sortable: false)) - add_field('custom_fields', ColumnSchema.new(column_type: 'Json', filter_operators: [], - is_read_only: true, is_sortable: false)) - add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, - is_read_only: true, is_sortable: true)) + 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 @@ -156,5 +112,3 @@ def define_relations end end end - -# rubocop:enable Metrics/ClassLength 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 index f038d230c..55a6795b7 100644 --- 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 @@ -10,6 +10,18 @@ module Helpers 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? 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 index 119722d04..07d42a614 100644 --- 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 @@ -21,11 +21,7 @@ class LinkAccountHolderToDirectDebitMandates < ForestAdminDatasourceCustomizer:: ONE_TO_MANY_NAME = 'direct_debit_mandates'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkAccountHolderToDirectDebitMandates must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(DIRECT_DEBIT_MANDATE) do |c| c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) 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 index e35ef33af..d3a17a3da 100644 --- 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 @@ -21,11 +21,7 @@ class LinkAccountHolderToIncomingPayments < ForestAdminDatasourceCustomizer::Plu ONE_TO_MANY_NAME = 'incoming_payments'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkAccountHolderToIncomingPayments must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) 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 index 89a290a1b..728b05ba9 100644 --- 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 @@ -14,11 +14,7 @@ class LinkExternalAccountToDirectDebitMandates < ForestAdminDatasourceCustomizer DIRECT_DEBIT_MANDATE = 'MambuDirectDebitMandate'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkExternalAccountToDirectDebitMandates must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| c.add_one_to_many_relation('direct_debit_mandates', DIRECT_DEBIT_MANDATE, 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 index 8cc13df9c..36c1ef0c8 100644 --- 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 @@ -14,11 +14,7 @@ class LinkExternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::P INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkExternalAccountToIncomingPayments must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, 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 index bb60e5e4e..94764842d 100644 --- 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 @@ -14,11 +14,7 @@ class LinkExternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plug PAYMENT_ORDER = 'MambuPaymentOrder'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkExternalAccountToPaymentOrders must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| c.add_one_to_many_relation('payment_orders', PAYMENT_ORDER, 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 index 5c6b76944..c4f6ee85e 100644 --- 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 @@ -19,11 +19,7 @@ class LinkIncomingPaymentToEvents < ForestAdminDatasourceCustomizer::Plugins::Pl EVENT = 'MambuEvent'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkIncomingPaymentToEvents must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| c.add_one_to_many_relation('events', EVENT, 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 index 3b770b61d..d9fa524b6 100644 --- 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 @@ -25,11 +25,7 @@ class LinkIncomingPaymentToExpectedPayments < ForestAdminDatasourceCustomizer::P ONE_TO_MANY_NAME = 'matched_expected_payments'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkIncomingPaymentToExpectedPayments must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(EXPECTED_PAYMENT) do |c| # Virtual column: ExpectedPayment has no native incoming_payment_id. 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 index 38ddd6391..fc5f27ea6 100644 --- 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 @@ -18,11 +18,7 @@ class LinkIncomingPaymentToReturns < ForestAdminDatasourceCustomizer::Plugins::P RETURN_COLL = 'MambuReturn'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkIncomingPaymentToReturns must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| c.add_one_to_many_relation('returns', RETURN_COLL, 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 index 88486de1f..63b2caf2f 100644 --- 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 @@ -22,11 +22,7 @@ class LinkIncomingPaymentToTransactions < ForestAdminDatasourceCustomizer::Plugi ONE_TO_MANY_NAME = 'transactions'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkIncomingPaymentToTransactions must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(TRANSACTION) do |c| # Virtual column: Transaction has no native incoming_payment_id. 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 index 2a9c5e7ca..3333d66f8 100644 --- 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 @@ -21,11 +21,7 @@ class LinkInternalAccountToBalances < ForestAdminDatasourceCustomizer::Plugins:: ONE_TO_MANY_NAME = 'balances'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkInternalAccountToBalances must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(BALANCE) do |c| # Virtual column: Balance has no native internal_account_id. 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 index ead1708d6..d7778d24e 100644 --- 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 @@ -14,11 +14,7 @@ class LinkInternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::P INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkInternalAccountToIncomingPayments must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, 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 index 3fba2f984..1fe181178 100644 --- 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 @@ -21,11 +21,7 @@ class LinkInternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plug ONE_TO_MANY_NAME = 'payment_orders'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkInternalAccountToPaymentOrders must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| # Virtual column: PaymentOrder has no native internal_account_id. 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 index f59b449d7..2fd8591fe 100644 --- 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 @@ -19,11 +19,7 @@ class LinkPaymentOrderToEvents < ForestAdminDatasourceCustomizer::Plugins::Plugi EVENT = 'MambuEvent'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkPaymentOrderToEvents must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| c.add_one_to_many_relation('events', EVENT, 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 index b99f40a70..5640cce3c 100644 --- 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 @@ -24,11 +24,7 @@ class LinkPaymentOrderToReceivingAccountHolder < ForestAdminDatasourceCustomizer ONE_TO_MANY_NAME = 'payment_orders'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkPaymentOrderToReceivingAccountHolder must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) 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 index 0d7b68b80..8c2a5219a 100644 --- 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 @@ -18,11 +18,7 @@ class LinkPaymentOrderToReturns < ForestAdminDatasourceCustomizer::Plugins::Plug RETURN_COLL = 'MambuReturn'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkPaymentOrderToReturns must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| c.add_one_to_many_relation('returns', RETURN_COLL, 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 index 85b1beeb3..97db09278 100644 --- 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 @@ -21,11 +21,7 @@ class LinkPaymentOrderToTransactions < ForestAdminDatasourceCustomizer::Plugins: ONE_TO_MANY_NAME = 'transactions'.freeze def run(datasource_customizer, _collection_customizer = nil, _options = {}) - unless datasource_customizer - raise ArgumentError, - 'LinkPaymentOrderToTransactions must be installed at the datasource level ' \ - 'via @agent.use(plugin, {})' - end + Plugins::Helpers.require_datasource!(datasource_customizer, self.class) datasource_customizer.customize_collection(TRANSACTION) do |c| # Virtual column: Transaction has no native payment_order_id. 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..159c8f82e --- /dev/null +++ b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb @@ -0,0 +1,71 @@ +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 + + PAGE_SIZE = 100 + + 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`, paging + # until the result is exhausted, and returns the unique non-empty values + # of `field` (handles both scalar columns and array columns such as + # InternalAccount.connected_account_ids). + def collect(context, collection_name, condition_tree, field) + collection = context.datasource.get_collection(collection_name) + offset = 0 + values = [] + loop do + rows = collection.list( + Filter.new(condition_tree: condition_tree, page: Page.new(offset: offset, limit: PAGE_SIZE)), + Projection.new([field]) + ) + values.concat(rows.flat_map { |row| Array(row[field]) }) + break if rows.size < PAGE_SIZE + + offset += PAGE_SIZE + end + values.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 index 77e468da7..1b3f278f9 100644 --- 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 @@ -7,49 +7,31 @@ module Relations # 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). Only EQUAL/IN are handled (the - # operators Forest's OneToMany navigation actually uses). + # scoped by connected account). module TwoStepConnectedAccountFilter Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf - Filter = ForestAdminDatasourceToolkit::Components::Query::Filter - Projection = ForestAdminDatasourceToolkit::Components::Query::Projection INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze ARRAY_FIELD = 'connected_account_ids'.freeze FK_NAME = 'internal_account_id'.freeze - # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. - NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze - - SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze - def self.install(collection_customizer, target_field:) - SUPPORTED_OPS.each do |operator| + PivotResolution::SUPPORTED_OPS.each do |operator| collection_customizer.replace_field_operator(FK_NAME, operator) do |value, context| - ia_ids = TwoStepConnectedAccountFilter.normalize(value, operator) - next TwoStepConnectedAccountFilter.no_match(target_field) if ia_ids.empty? + ia_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(target_field) if ia_ids.empty? - ca_ids = context.datasource.get_collection(INTERNAL_ACCOUNT).list( - Filter.new(condition_tree: ConditionTreeLeaf.new('id', Operators::IN, ia_ids)), - Projection.new([ARRAY_FIELD]) - ).flat_map { |r| Array(r[ARRAY_FIELD]) }.compact.uniq - - next TwoStepConnectedAccountFilter.no_match(target_field) if ca_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 - - def self.normalize(value, operator) - values = operator == Operators::IN ? Array(value) : [value] - values.compact.reject { |v| v.to_s.empty? }.uniq - end - - def self.no_match(target_field) - ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) - 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 index 8764b5307..dbb8720e6 100644 --- 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 @@ -12,70 +12,42 @@ module Relations # 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. - # Only EQUAL/IN are handled (the operators Forest's OneToMany navigation - # actually uses). module TwoStepCrossReconciliationFilter - 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 + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf RECONCILIATION = 'MambuReconciliation'.freeze - # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. - NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze - - SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze - def self.install(collection_customizer, fk_name:, src_payment_type:, dst_payment_type:, target_field:) - SUPPORTED_OPS.each do |operator| + PivotResolution::SUPPORTED_OPS.each do |operator| collection_customizer.replace_field_operator(fk_name, operator) do |value, context| - src_ids = TwoStepCrossReconciliationFilter.normalize(value, operator) - next TwoStepCrossReconciliationFilter.no_match(target_field) if src_ids.empty? + src_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(target_field) if src_ids.empty? - tx_ids = TwoStepCrossReconciliationFilter.resolve_transactions(context, src_ids, src_payment_type) - next TwoStepCrossReconciliationFilter.no_match(target_field) if tx_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_payments(context, tx_ids, dst_payment_type) - next TwoStepCrossReconciliationFilter.no_match(target_field) if dst_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 - def self.resolve_transactions(context, src_ids, src_payment_type) - condition = ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('payment_id', Operators::IN, src_ids), - ConditionTreeLeaf.new('payment_type', Operators::EQUAL, - src_payment_type) - ]) - context.datasource.get_collection(RECONCILIATION).list( - Filter.new(condition_tree: condition), - Projection.new(['transaction_id']) - ).filter_map { |r| r['transaction_id'] }.uniq - end - - def self.resolve_payments(context, tx_ids, dst_payment_type) - condition = ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('transaction_id', Operators::IN, tx_ids), - ConditionTreeLeaf.new('payment_type', Operators::EQUAL, - dst_payment_type) - ]) - context.datasource.get_collection(RECONCILIATION).list( - Filter.new(condition_tree: condition), - Projection.new(['payment_id']) - ).filter_map { |r| r['payment_id'] }.uniq - end - - def self.normalize(value, operator) - values = operator == Operators::IN ? Array(value) : [value] - values.compact.reject { |v| v.to_s.empty? }.uniq - end - - def self.no_match(target_field) - ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) + # 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 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 index fc7b30fd2..19f7ab6f7 100644 --- 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 @@ -5,47 +5,27 @@ module Relations # 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)`. Only EQUAL/IN are handled (the operators - # Forest's OneToMany navigation actually uses). + # `local_fk IN (ids)`. module TwoStepHolderFilter Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf - Filter = ForestAdminDatasourceToolkit::Components::Query::Filter - Projection = ForestAdminDatasourceToolkit::Components::Query::Projection - - # All-zero UUID: guaranteed not to exist in Numeral, so the native - # list returns []. Used to express "match nothing" without tripping - # the empty-IN guard in ConditionTreeTranslator. - NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze - - SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze def self.install(collection_customizer, fk_name:, local_fk:, intermediate_collection:) - SUPPORTED_OPS.each do |operator| + PivotResolution::SUPPORTED_OPS.each do |operator| collection_customizer.replace_field_operator(fk_name, operator) do |value, context| - holder_ids = TwoStepHolderFilter.normalize(value, operator) - next TwoStepHolderFilter.no_match(local_fk) if holder_ids.empty? + holder_ids = PivotResolution.normalize(value, operator) + next PivotResolution.no_match(local_fk) if holder_ids.empty? - fk_ids = context.datasource.get_collection(intermediate_collection).list( - Filter.new(condition_tree: ConditionTreeLeaf.new(fk_name, Operators::IN, holder_ids)), - Projection.new(['id']) - ).map { |r| r['id'] }.uniq - - next TwoStepHolderFilter.no_match(local_fk) if fk_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 - - def self.normalize(value, operator) - values = operator == Operators::IN ? Array(value) : [value] - values.compact.reject { |v| v.to_s.empty? }.uniq - end - - def self.no_match(local_fk) - ConditionTreeLeaf.new(local_fk, Operators::EQUAL, NO_MATCH_SENTINEL) - 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 index c923855fa..2d43d5d30 100644 --- 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 @@ -7,54 +7,32 @@ module Relations # 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. - # Only EQUAL/IN are handled (the operators Forest's OneToMany navigation - # actually uses). module TwoStepReconciliationFilter - 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 + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf RECONCILIATION = 'MambuReconciliation'.freeze - # See TwoStepHolderFilter::NO_MATCH_SENTINEL for the rationale. - NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze - - SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze - def self.install(collection_customizer, fk_name:, payment_type:, target_field:) - SUPPORTED_OPS.each do |operator| + PivotResolution::SUPPORTED_OPS.each do |operator| collection_customizer.replace_field_operator(fk_name, operator) do |value, context| - payment_ids = TwoStepReconciliationFilter.normalize(value, operator) - next TwoStepReconciliationFilter.no_match(target_field) if payment_ids.empty? - - condition = ConditionTreeBranch.new('And', [ - ConditionTreeLeaf.new('payment_id', Operators::IN, payment_ids), - ConditionTreeLeaf.new('payment_type', Operators::EQUAL, - payment_type) - ]) - - tx_ids = context.datasource.get_collection(RECONCILIATION).list( - Filter.new(condition_tree: condition), - Projection.new(['transaction_id']) - ).filter_map { |r| r['transaction_id'] }.uniq - - next TwoStepReconciliationFilter.no_match(target_field) if tx_ids.empty? + 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 - - def self.normalize(value, operator) - values = operator == Operators::IN ? Array(value) : [value] - values.compact.reject { |v| v.to_s.empty? }.uniq - end - - def self.no_match(target_field) - ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL) - end end end 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 index 6e58b756a..21420422c 100644 --- 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 @@ -447,4 +447,52 @@ def json(payload, status = 200) .to raise_error(ForestAdminDatasourceMambuPayments::APIError, %r{approve\(payment_orders/bad\)}) end end + + describe 'server-side count' do + it 'reads the total off the list envelope without materializing records' do + stub_request(:get, "#{base}/connected_accounts") + .with(query: hash_including('limit' => '1')) + .to_return(json('records' => [{ 'id' => 'a' }], 'total' => 4200)) + + expect(client.count_connected_accounts).to eq(4200) + end + + it 'forwards filter params to the count endpoint' do + stub_request(:get, "#{base}/incoming_payments") + .with(query: hash_including('connected_account_id' => 'acc1', 'limit' => '1')) + .to_return(json('total' => 7)) + + expect(client.count_incoming_payments(connected_account_id: 'acc1')).to eq(7) + end + + it 'falls back to the record count when the API omits total' do + stub_request(:get, "#{base}/transactions") + .with(query: hash_including('limit' => '1')) + .to_return(json('records' => [{ 'id' => 'a' }, { 'id' => 'b' }])) + expect(client.count_transactions).to eq(2) + 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 index 8e5bc4546..b36835509 100644 --- 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 @@ -78,8 +78,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_account_holders).and_return([holder, holder]) + it 'counts via the server-side total' do + allow(client).to receive(:count_account_holders).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 351883adc..a72277b0c 100644 --- 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 @@ -86,8 +86,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_balances).and_return([balance]) + it 'counts via the server-side total' do + allow(client).to receive(:count_balances).and_return(1) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(1) 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 index 8851958b9..a767aa328 100644 --- 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 @@ -59,8 +59,10 @@ module ForestAdminDatasourceMambuPayments expect(result).to eq('id' => '1') end - it 'returns the full record when projection has only relation prefixes' do - expect(collection.send(:project, record, ['connected_account:name'])).to eq(record) + 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 @@ -107,58 +109,58 @@ module ForestAdminDatasourceMambuPayments { 'connected_account_id' => '' } # blank ignored ] end - let(:fetcher) { instance_double(Proc) } + let(:batch_fetcher) { instance_double(Proc) } let(:serializer) { ->(raw) { { 'id' => raw['id'], 'name' => raw['name'] } } } - it 'fetches each unique FK once and assigns the serialized record' do - allow(fetcher).to receive(:call).with('a').and_return('id' => 'a', 'name' => 'Acme') + it 'fetches the unique FKs in a single batch and assigns the serialized record' do + allow(batch_fetcher).to receive(:call).with(['a']).and_return([{ 'id' => 'a', 'name' => 'Acme' }]) collection.send(:embed_many_to_one, rows, sources, projection, foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: fetcher, serializer: serializer) + batch_fetcher: batch_fetcher, serializer: serializer) 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(fetcher).to have_received(:call).with('a').once + expect(batch_fetcher).to have_received(:call).with(['a']).once end it 'does nothing when the projection does not request the relation' do - allow(fetcher).to receive(:call) + allow(batch_fetcher).to receive(:call) collection.send(:embed_many_to_one, rows, sources, ['id'], foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: fetcher, serializer: serializer) + batch_fetcher: batch_fetcher, serializer: serializer) expect(rows).to all(satisfy { |r| !r.key?('connected_account') }) - expect(fetcher).not_to have_received(:call) + expect(batch_fetcher).not_to have_received(:call) end it 'does nothing when projection is nil' do - allow(fetcher).to receive(:call) + allow(batch_fetcher).to receive(:call) collection.send(:embed_many_to_one, rows, sources, nil, foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: fetcher, serializer: serializer) - expect(fetcher).not_to have_received(:call) + batch_fetcher: batch_fetcher, serializer: serializer) + expect(batch_fetcher).not_to have_received(:call) end it 'does nothing when no source has a usable FK' do - allow(fetcher).to receive(:call) + allow(batch_fetcher).to receive(:call) empty_sources = [{ 'connected_account_id' => nil }, { 'connected_account_id' => '' }] collection.send(:embed_many_to_one, [{ 'id' => 'a' }, { 'id' => 'b' }], empty_sources, projection, foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: fetcher, serializer: serializer) + batch_fetcher: batch_fetcher, serializer: serializer) - expect(fetcher).not_to have_received(:call) + expect(batch_fetcher).not_to have_received(:call) end - it 'drops rows whose fetcher returned nil (record not found)' do - allow(fetcher).to receive(:call).with('a').and_return(nil) + it 'leaves the relation nil for rows whose record is missing from the batch' do + allow(batch_fetcher).to receive(:call).with(['a']).and_return([]) collection.send(:embed_many_to_one, rows, sources, projection, foreign_key: 'connected_account_id', relation_name: 'connected_account', - fetcher: fetcher, serializer: serializer) + batch_fetcher: batch_fetcher, serializer: serializer) expect(rows[0]['connected_account']).to be_nil expect(rows[1]['connected_account']).to be_nil @@ -170,16 +172,45 @@ module ForestAdminDatasourceMambuPayments expect(collection.send(:translate_filters, nil)).to eq({}) end - it 'raises on any non-id predicate by default (api_filters is empty on the base class)' do - leaf = Leaf.new('connected_account_id', 'equal', 'acc1') + 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(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } let(:filter) { ForestAdminDatasourceToolkit::Components::Query::Filter.new } + before { allow(datasource).to receive(:client).and_return(client) } + + it 'counts via the server-side total rather than listing records' do + allow(client).to receive(:count_connected_accounts).and_return(4200) + agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count') + expect(collection.aggregate(nil, filter, agg)).to eq([{ 'value' => 4200, 'group' => {} }]) + end + it 'raises on non-Count aggregations' do agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Sum', field: 'amount') expect { collection.aggregate(nil, filter, agg) } @@ -193,5 +224,25 @@ module ForestAdminDatasourceMambuPayments .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) end end + + describe '#fetch_page over a window larger than one API page' do + let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } + let(:page_class) { ForestAdminDatasourceToolkit::Components::Query::Page } + + before { allow(datasource).to receive(:client).and_return(client) } + + it 'walks successive pages and slices to the requested [offset, offset + limit)' do + full_page = Array.new(100) { |i| { 'id' => "p1-#{i}" } } + second_page = Array.new(100) { |i| { 'id' => "p2-#{i}" } } + allow(client).to receive(:list_connected_accounts).with(page: 1, limit: 100).and_return(full_page) + allow(client).to receive(:list_connected_accounts).with(page: 2, limit: 100).and_return(second_page) + + rows = collection.send(:fetch_page, page_class.new(offset: 0, limit: 150), {}) + + expect(rows.size).to eq(150) + expect(rows.first['id']).to eq('p1-0') + expect(rows.last['id']).to eq('p2-49') + 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 index 97ede2f6a..e1213e404 100644 --- 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 @@ -167,8 +167,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_claims).and_return([claim, claim]) + it 'counts via the server-side total' do + allow(client).to receive(:count_claims).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 4ac3acba0..d5bb811e0 100644 --- 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 @@ -95,8 +95,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_connected_accounts).and_return([account, account]) + it 'counts via the server-side total' do + allow(client).to receive(:count_connected_accounts).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index e771fb3a3..e4c610f42 100644 --- 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 @@ -182,8 +182,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_events).and_return([payment_order_event, transaction_event]) + it 'counts via the server-side total' do + allow(client).to receive(:count_events).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 7f4417d7d..deca77ea7 100644 --- 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 @@ -52,7 +52,6 @@ module ForestAdminDatasourceMambuPayments 'connected_account_id', 'internal_account_id', 'external_account_id', 'direction', 'amount_from', 'amount_to', 'currency', 'start_date', 'end_date', 'descriptions', - 'internal_account_snapshot', 'external_account_snapshot', 'reconciliation_status', 'reconciled_amount', 'custom_fields', 'metadata', 'created_at', 'updated_at', 'canceled_at' @@ -61,9 +60,12 @@ module ForestAdminDatasourceMambuPayments 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].each do |k| + 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 @@ -81,17 +83,14 @@ module ForestAdminDatasourceMambuPayments expect(f['direction'].enum_values).to contain_exactly('debit', 'credit') end - it 'keeps account snapshots and descriptions as Json' do + it 'keeps descriptions as Json' do f = collection.schema[:fields] - %w[internal_account_snapshot external_account_snapshot descriptions].each do |k| - expect(f[k].column_type).to eq('Json') - end + expect(f['descriptions'].column_type).to eq('Json') end - it 'marks reconciliation outcome, snapshots and timestamps as read-only' do + it 'marks reconciliation outcome and timestamps as read-only' do f = collection.schema[:fields] %w[id object reconciliation_status reconciled_amount - internal_account_snapshot external_account_snapshot created_at updated_at canceled_at].each do |k| expect(f[k].is_read_only).to be(true), "#{k} should be read-only" end @@ -99,20 +98,17 @@ module ForestAdminDatasourceMambuPayments end describe '#list' do - it 'serializes amount_from/to, start/end_date and exposes account snapshots' 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 - internal_account_snapshot external_account_snapshot descriptions]) + %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'] ) - expect(rows.first['external_account_snapshot']).to include('account_number' => 'AG454545') - expect(rows.first['internal_account_snapshot']).to include('account_number' => '43244675643525') end it 'returns rows without resolving relations when projection has no relation prefix' do @@ -131,7 +127,8 @@ module ForestAdminDatasourceMambuPayments 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) + .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') @@ -162,12 +159,11 @@ module ForestAdminDatasourceMambuPayments end describe '#create' do - it 'strips system-managed fields and snapshots before POSTing' 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', - 'internal_account_snapshot', 'external_account_snapshot') + 'created_at', 'updated_at', 'canceled_at') { 'id' => 'ep1', 'amount_from' => 5000 } end @@ -175,8 +171,6 @@ module ForestAdminDatasourceMambuPayments 'id' => 'ignored', 'object' => 'expected_payment', 'reconciliation_status' => 'unreconciled', 'reconciled_amount' => 0, 'created_at' => 't', 'updated_at' => 't', 'canceled_at' => nil, - 'internal_account_snapshot' => { 'a' => 'b' }, - 'external_account_snapshot' => { 'a' => 'b' }, 'amount_from' => 5000, 'amount_to' => 6000, 'direction' => 'debit') expect(client).to have_received(:create_expected_payment) 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 index c7d666dc9..972b52600 100644 --- 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 @@ -114,8 +114,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_files).and_return([file_record, file_record]) + it 'counts via the server-side total' do + allow(client).to receive(:count_files).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 817961d69..02b617101 100644 --- 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 @@ -130,8 +130,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_incoming_payments).and_return([incoming_payment, incoming_payment]) + it 'counts via the server-side total' do + allow(client).to receive(:count_incoming_payments).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 8094eecd2..cdc779371 100644 --- 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 @@ -165,9 +165,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_payee_verification_requests) - .and_return([payee_verification_request, payee_verification_request]) + it 'counts via the server-side total' do + allow(client).to receive(:count_payee_verification_requests).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index a339deaa5..2ee2740da 100644 --- 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 @@ -185,8 +185,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_payment_captures).and_return([payment_capture, payment_capture]) + it 'counts via the server-side total' do + allow(client).to receive(:count_payment_captures).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 23a2b2e1f..db7696f27 100644 --- 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 @@ -159,8 +159,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_reconciliations).and_return([reconciliation, reconciliation]) + it 'counts via the server-side total' do + allow(client).to receive(:count_reconciliations).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index a9aa79d2d..0ad89bda3 100644 --- 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 @@ -163,8 +163,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_returns).and_return([return_record, return_record]) + it 'counts via the server-side total' do + allow(client).to receive(:count_returns).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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 index 5ad250b1e..f0549da61 100644 --- 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 @@ -111,8 +111,8 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate Count' do - it 'counts via list with a minimal projection' do - allow(client).to receive(:list_transactions).and_return([transaction, transaction]) + it 'counts via the server-side total' do + allow(client).to receive(:count_transactions).and_return(2) result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) expect(result.first['value']).to eq(2) 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/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 From a25416d1006f8ace7f8a40950e736a626d7d0f94 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 18:39:50 +0200 Subject: [PATCH 21/24] fix(mambu_payments): use Numeral cursor pagination Numeral list endpoints paginate with a starting_after cursor plus a limit (max 100), not page/limit. Walk the cursor forward to cover Forest's requested [offset, offset + limit) window, then slice; the first page of a small list stays a single request. Two-step relation resolution now asks for one large window so the cursor walk fetches every matching row in a single forward pass (capped and logged rather than silently truncated). Specs: 498 examples, 0 failures; rubocop clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collections/base_collection.rb | 50 +++++++------- .../plugins/relations/pivot_resolution.rb | 36 +++++----- .../collections/account_holder_spec.rb | 2 +- .../collections/base_collection_spec.rb | 68 ++++++++++--------- .../collections/claim_spec.rb | 2 +- .../collections/connected_account_spec.rb | 2 +- .../collections/payment_order_spec.rb | 2 +- .../collections/reconciliation_spec.rb | 2 +- .../collections/return_spec.rb | 2 +- 9 files changed, 86 insertions(+), 80 deletions(-) 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 index 6c661df2b..b146592e2 100644 --- 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 @@ -102,7 +102,7 @@ def fetch_records(filter) ids = extract_id_lookup(filter.condition_tree) return fetch_by_ids(ids) if ids - fetch_page(filter.page, translate_filters(filter.condition_tree)) + paginate(filter.page, translate_filters(filter.condition_tree)) end # Resolves a set of ids via the per-id `find_*` endpoint. Numeral has no @@ -115,29 +115,35 @@ def fetch_by_ids(ids) ids.filter_map { |id| client_find(id) } end - def fetch_page(page, params) - return fetch_window(page, params) if page&.limit && page.limit > Client::MAX_PER_PAGE + # Numeral paginates with a `starting_after` cursor (the id of the last seen + # record) plus a `limit` capped at MAX_PER_PAGE — it has no offset/page + # parameter. Forest, however, asks for an [offset, offset + limit) window, + # so we walk forward in cursor pages until the window is covered, then + # slice. The first page of a small list is a single request. + def paginate(page, params) + offset = page&.offset.to_i + limit = page&.limit + limit = Client::MAX_PER_PAGE if limit.nil? || limit <= 0 + needed = offset + limit - page_num, per_page = translate_page(page) - client_list(**params, page: page_num, limit: per_page) - end - - # Fetches a window larger than one API page by walking successive pages of - # MAX_PER_PAGE and slicing to the requested [offset, offset + limit) range. - def fetch_window(page, params) - offset = page.offset.to_i - limit = page.limit.to_i - start = offset % Client::MAX_PER_PAGE - page_num = (offset / Client::MAX_PER_PAGE) + 1 collected = [] + cursor = nil loop do - batch = client_list(**params, page: page_num, limit: Client::MAX_PER_PAGE) + chunk = [needed - collected.size, Client::MAX_PER_PAGE].min + page_params = params.merge(limit: chunk) + page_params[:starting_after] = cursor if cursor + batch = client_list(**page_params) collected.concat(batch) - break if batch.size < Client::MAX_PER_PAGE || collected.size >= start + limit + break if batch.size < chunk || collected.size >= needed - page_num += 1 + cursor = record_id(batch.last) + break if cursor.to_s.empty? end - collected[start, limit] || [] + collected[offset, limit] || [] + end + + def record_id(record) + attrs_of(record)['id'] end def count_records(filter) @@ -183,14 +189,6 @@ def project(record, projection) wanted.to_h { |k| [k, record[k]] } end - def translate_page(page) - return [1, Client::MAX_PER_PAGE] if page.nil? - - per_page = page.limit&.positive? ? [page.limit, Client::MAX_PER_PAGE].min : Client::MAX_PER_PAGE - page_num = (page.offset.to_i / per_page) + 1 - [page_num, per_page] - 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. 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 index 159c8f82e..549dabe6d 100644 --- 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 @@ -28,7 +28,11 @@ module PivotResolution # navigation actually emits. SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze - PAGE_SIZE = 100 + # 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 @@ -45,25 +49,23 @@ def and_branch(*leaves) ConditionTreeBranch.new('And', leaves) end - # Lists every row of `collection_name` matching `condition_tree`, paging - # until the result is exhausted, and returns the unique non-empty values - # of `field` (handles both scalar columns and array columns such as - # InternalAccount.connected_account_ids). + # 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) - collection = context.datasource.get_collection(collection_name) - offset = 0 - values = [] - loop do - rows = collection.list( - Filter.new(condition_tree: condition_tree, page: Page.new(offset: offset, limit: PAGE_SIZE)), - Projection.new([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." ) - values.concat(rows.flat_map { |row| Array(row[field]) }) - break if rows.size < PAGE_SIZE - - offset += PAGE_SIZE end - values.compact.reject { |v| v.to_s.empty? }.uniq + rows.flat_map { |row| Array(row[field]) }.compact.reject { |v| v.to_s.empty? }.uniq 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 index b36835509..fd0286366 100644 --- 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 @@ -73,7 +73,7 @@ module ForestAdminDatasourceMambuPayments 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(page: 1, limit: Client::MAX_PER_PAGE) + expect(client).to have_received(:list_account_holders).with(limit: Client::MAX_PER_PAGE) 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 index a767aa328..55554e9d6 100644 --- 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 @@ -66,21 +66,47 @@ module ForestAdminDatasourceMambuPayments end end - describe '#translate_page' do - it 'defaults to page 1 / MAX_PER_PAGE when no page is given' do - expect(collection.send(:translate_page, nil)) - .to eq([1, ForestAdminDatasourceMambuPayments::Client::MAX_PER_PAGE]) + 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 'computes page number from offset / limit' do - page = ForestAdminDatasourceToolkit::Components::Query::Page.new(limit: 10, offset: 20) - expect(collection.send(:translate_page, page)).to eq([3, 10]) + 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 'caps the limit at MAX_PER_PAGE' do - page = ForestAdminDatasourceToolkit::Components::Query::Page.new(limit: 999, offset: 0) - _, per_page = collection.send(:translate_page, page) - expect(per_page).to eq(ForestAdminDatasourceMambuPayments::Client::MAX_PER_PAGE) + 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 @@ -224,25 +250,5 @@ module ForestAdminDatasourceMambuPayments .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) end end - - describe '#fetch_page over a window larger than one API page' do - let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } - let(:page_class) { ForestAdminDatasourceToolkit::Components::Query::Page } - - before { allow(datasource).to receive(:client).and_return(client) } - - it 'walks successive pages and slices to the requested [offset, offset + limit)' do - full_page = Array.new(100) { |i| { 'id' => "p1-#{i}" } } - second_page = Array.new(100) { |i| { 'id' => "p2-#{i}" } } - allow(client).to receive(:list_connected_accounts).with(page: 1, limit: 100).and_return(full_page) - allow(client).to receive(:list_connected_accounts).with(page: 2, limit: 100).and_return(second_page) - - rows = collection.send(:fetch_page, page_class.new(offset: 0, limit: 150), {}) - - expect(rows.size).to eq(150) - expect(rows.first['id']).to eq('p1-0') - expect(rows.last['id']).to eq('p2-49') - 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 index e1213e404..1570ed8a5 100644 --- 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 @@ -143,7 +143,7 @@ module ForestAdminDatasourceMambuPayments collection.list(nil, filter, ['id']) expect(client).to have_received(:list_claims) - .with(hash_including('related_payment_id' => 'po1', page: 1)) + .with(hash_including('related_payment_id' => 'po1')) end it 'forwards status and type filters to the API' do 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 index d5bb811e0..c9891fa87 100644 --- 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 @@ -84,7 +84,7 @@ module ForestAdminDatasourceMambuPayments 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(page: 1, limit: Client::MAX_PER_PAGE) + expect(client).to have_received(:list_connected_accounts).with(limit: Client::MAX_PER_PAGE) end it 'drops 404 (nil) records from the result' do 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 index d0ed0680b..422a9b11a 100644 --- 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 @@ -119,7 +119,7 @@ module ForestAdminDatasourceMambuPayments collection.list(nil, filter, ['id']) expect(client).to have_received(:list_payment_orders) - .with(hash_including('connected_account_id' => 'acc1', page: 1)) + .with(hash_including('connected_account_id' => 'acc1')) end it 'forwards receiving_account_id as external_account_id (Numeral list param)' do 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 index db7696f27..fc21d7843 100644 --- 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 @@ -135,7 +135,7 @@ module ForestAdminDatasourceMambuPayments collection.list(nil, filter, ['id']) expect(client).to have_received(:list_reconciliations) - .with(hash_including('transaction_id' => 'tx1', page: 1)) + .with(hash_including('transaction_id' => 'tx1')) end it 'forwards payment_id, payment_type and match_type filters to the API' do 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 index 0ad89bda3..20cae3263 100644 --- 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 @@ -139,7 +139,7 @@ module ForestAdminDatasourceMambuPayments collection.list(nil, filter, ['id']) expect(client).to have_received(:list_returns) - .with(hash_including('related_payment_id' => 'ip1', page: 1)) + .with(hash_including('related_payment_id' => 'ip1')) end it 'forwards status and connected_account_id filters to the API' do From c223a5643c2b2662e16feb61a684c5dbb1ea8e74 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 18:52:03 +0200 Subject: [PATCH 22/24] fix(mambu_payments): drop server-side count (unsupported) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeral list responses are { "records": [...] } only — no total, total_count, has_more or count, and there is no count endpoint (confirmed against the OpenAPI spec). The previous count read a non-existent `total` and silently fell back to a 1-record page, so every count returned 0 or 1. Declare the collections non-countable (drop enable_count) and remove the count_* client methods and count path. Forest no longer shows a total in the table footer, which is honest: the API cannot count without scanning every cursor page. aggregate now raises a clear "not countable" error if ever called. Specs: 482 examples, 0 failures; rubocop clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client.rb | 18 ------------- .../client/reads.rb | 17 ------------- .../collections/account_holder.rb | 1 - .../collections/balance.rb | 1 - .../collections/base_collection.rb | 25 ++++++------------- .../collections/claim.rb | 1 - .../collections/connected_account.rb | 1 - .../collections/direct_debit_mandate.rb | 1 - .../collections/event.rb | 1 - .../collections/expected_payment.rb | 1 - .../collections/external_account.rb | 1 - .../collections/file.rb | 1 - .../collections/incoming_payment.rb | 1 - .../collections/internal_account.rb | 1 - .../collections/payee_verification_request.rb | 1 - .../collections/payment_capture.rb | 1 - .../collections/payment_order.rb | 1 - .../collections/reconciliation.rb | 1 - .../collections/return.rb | 1 - .../collections/transaction.rb | 1 - .../client_spec.rb | 25 ------------------- .../collections/account_holder_spec.rb | 8 ------ .../collections/balance_spec.rb | 8 ------ .../collections/base_collection_spec.rb | 20 +++------------ .../collections/claim_spec.rb | 8 ------ .../collections/connected_account_spec.rb | 8 ------ .../collections/event_spec.rb | 8 ------ .../collections/file_spec.rb | 8 ------ .../collections/incoming_payment_spec.rb | 8 ------ .../payee_verification_request_spec.rb | 8 ------ .../collections/payment_capture_spec.rb | 8 ------ .../collections/reconciliation_spec.rb | 8 ------ .../collections/return_spec.rb | 8 ------ .../collections/transaction_spec.rb | 8 ------ 34 files changed, 12 insertions(+), 206 deletions(-) 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 index 4712c8981..f2acf2650 100644 --- 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 @@ -19,16 +19,6 @@ def list_resource(path, params = {}) end end - # Server-side count. Numeral list responses carry a `total` field, so we - # ask for a single record and read the total off the envelope rather than - # materializing (and capping at one page of) the whole collection. - def count_resource(path, params = {}) - must_succeed("count(#{path})") do - body = connection.get(path, normalize_params(params.merge(limit: 1))).body - extract_total(body, path) - end - end - def get_resource(path, id) extract_record(connection.get("#{path}/#{id}").body) rescue Faraday::ResourceNotFound @@ -89,14 +79,6 @@ def extract_record(body) body end - # Reads the `total` count off a list envelope, falling back to the size of - # the returned records when the API omits it (e.g. an array body). - def extract_total(body, path) - return body['total'].to_i if body.is_a?(Hash) && body.key?('total') - - extract_records(body, path).size - end - def delete_resource(path, id) must_succeed("delete(#{path}/#{id})") do connection.delete("#{path}/#{id}") 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 index b76f023aa..535cb4d19 100644 --- 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 @@ -2,73 +2,57 @@ module ForestAdminDatasourceMambuPayments class Client module Reads def list_connected_accounts(**params) = list_resource('connected_accounts', params) - def count_connected_accounts(**params) = count_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 count_payment_orders(**params) = count_resource('payment_orders', params) def find_payment_order(id) = get_resource('payment_orders', id) def list_transactions(**params) = list_resource('transactions', params) - def count_transactions(**params) = count_resource('transactions', params) def find_transaction(id) = get_resource('transactions', id) def list_balances(**params) = list_resource('balances', params) - def count_balances(**params) = count_resource('balances', params) def find_balance(id) = get_resource('balances', id) def list_account_holders(**params) = list_resource('account_holders', params) - def count_account_holders(**params) = count_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 count_external_accounts(**params) = count_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 count_internal_accounts(**params) = count_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 count_incoming_payments(**params) = count_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 count_direct_debit_mandates(**params) = count_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 count_expected_payments(**params) = count_resource('expected_payments', params) def find_expected_payment(id) = get_resource('expected_payments', id) def list_events(**params) = list_resource('events', params) - def count_events(**params) = count_resource('events', params) def find_event(id) = get_resource('events', id) def list_files(**params) = list_resource('files', params) - def count_files(**params) = count_resource('files', params) def find_file(id) = get_resource('files', id) def list_returns(**params) = list_resource('returns', params) - def count_returns(**params) = count_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 count_claims(**params) = count_resource('claims', params) def find_claim(id) = get_resource('claims', id) def list_reconciliations(**params) = list_resource('reconciliations', params) - def count_reconciliations(**params) = count_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 count_payment_captures(**params) = count_resource('payment_captures', params) def find_payment_capture(id) = get_resource('payment_captures', id) # Payee verification requests are emitted by Numeral when an outgoing @@ -76,7 +60,6 @@ def find_payment_capture(id) = get_resource('payment_captures', id) # 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 count_payee_verification_requests(**params) = count_resource('payee_verification_requests', params) def find_payee_verification_request(id) = get_resource('payee_verification_requests', id) 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 index 452cc9e8f..ba820f7ab 100644 --- 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 @@ -10,7 +10,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 42f6b23b6..bfb160192 100644 --- 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 @@ -12,7 +12,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index b146592e2..5b8639499 100644 --- 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 @@ -53,12 +53,14 @@ def list(_caller, filter, projection) rows end - def aggregate(_caller, filter, aggregation, _limit = nil) - unless aggregation.operation == 'Count' && aggregation.field.nil? && aggregation.groups.empty? - raise ForestException, 'Mambu Payments datasource only supports Count aggregation without groups.' - end - - [{ 'value' => count_records(filter), 'group' => {} }] + # 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 protected @@ -146,21 +148,10 @@ def record_id(record) attrs_of(record)['id'] end - def count_records(filter) - ids = extract_id_lookup(filter.condition_tree) - return fetch_by_ids(ids).size if ids - - client_count(**translate_filters(filter.condition_tree)) - end - def client_list(**params) datasource.client.public_send("list_#{self.class.resource_plural}", **params) end - def client_count(**params) - datasource.client.public_send("count_#{self.class.resource_plural}", **params) - end - def client_find(id) datasource.client.public_send("find_#{self.class.resource_singular}", id) 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 index 1caceccfb..09e637673 100644 --- 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 @@ -14,7 +14,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index 38117ad98..930fdd201 100644 --- 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 @@ -11,7 +11,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index a71fea75b..db8027de6 100644 --- 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 @@ -14,7 +14,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 463d96e36..6301a204e 100644 --- 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 @@ -28,7 +28,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index 9ee9e7472..52766b4d6 100644 --- 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 @@ -13,7 +13,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 8980a4592..cd8a04b55 100644 --- 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 @@ -11,7 +11,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index be97daeb8..46bdfc1ff 100644 --- 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 @@ -13,7 +13,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index b998ab6d5..12ddcee67 100644 --- 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 @@ -11,7 +11,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index 58e2e4331..58b2e9478 100644 --- 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 @@ -11,7 +11,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 1eb68b8e0..69666e36c 100644 --- 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 @@ -19,7 +19,6 @@ def initialize(datasource) super(datasource, 'MambuPayeeVerificationRequest') define_schema reconcile_filter_operators! - enable_count end def serialize(record) 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 index fa3761373..c8617206b 100644 --- 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 @@ -15,7 +15,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index fc7c89202..8ca9d915e 100644 --- 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 @@ -13,7 +13,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 324ce696f..f9cdb5e57 100644 --- 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 @@ -13,7 +13,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index 8f0003f71..b15b0ab96 100644 --- 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 @@ -17,7 +17,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def create(_caller, data) 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 index e51a45bf8..2a00d0d13 100644 --- 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 @@ -12,7 +12,6 @@ def initialize(datasource) define_schema define_relations reconcile_filter_operators! - enable_count end def serialize(record) 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 index 21420422c..455c54354 100644 --- 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 @@ -448,31 +448,6 @@ def json(payload, status = 200) end end - describe 'server-side count' do - it 'reads the total off the list envelope without materializing records' do - stub_request(:get, "#{base}/connected_accounts") - .with(query: hash_including('limit' => '1')) - .to_return(json('records' => [{ 'id' => 'a' }], 'total' => 4200)) - - expect(client.count_connected_accounts).to eq(4200) - end - - it 'forwards filter params to the count endpoint' do - stub_request(:get, "#{base}/incoming_payments") - .with(query: hash_including('connected_account_id' => 'acc1', 'limit' => '1')) - .to_return(json('total' => 7)) - - expect(client.count_incoming_payments(connected_account_id: 'acc1')).to eq(7) - end - - it 'falls back to the record count when the API omits total' do - stub_request(:get, "#{base}/transactions") - .with(query: hash_including('limit' => '1')) - .to_return(json('records' => [{ 'id' => 'a' }, { 'id' => 'b' }])) - expect(client.count_transactions).to eq(2) - 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") 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 index fd0286366..b017f82fc 100644 --- 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 @@ -77,14 +77,6 @@ module ForestAdminDatasourceMambuPayments end end - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_account_holders).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - end - end - describe '#create' do it 'POSTs the payload stripping system-managed fields' do allow(client).to receive(:create_account_holder) do |payload| 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 index a72277b0c..160236802 100644 --- 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 @@ -84,13 +84,5 @@ module ForestAdminDatasourceMambuPayments expect(client).not_to have_received(:list_balances) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_balances).and_return(1) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(1) - 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 index 55554e9d6..f3c0a4e42 100644 --- 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 @@ -226,28 +226,16 @@ module ForestAdminDatasourceMambuPayments end describe '#aggregate' do - let(:client) { instance_double(ForestAdminDatasourceMambuPayments::Client) } let(:filter) { ForestAdminDatasourceToolkit::Components::Query::Filter.new } - before { allow(datasource).to receive(:client).and_return(client) } - - it 'counts via the server-side total rather than listing records' do - allow(client).to receive(:count_connected_accounts).and_return(4200) + 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 eq([{ 'value' => 4200, 'group' => {} }]) - end - - it 'raises on non-Count aggregations' do - agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Sum', field: 'amount') expect { collection.aggregate(nil, filter, agg) } - .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /Count/) + .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, /not countable/) end - it 'raises on Count with groups' do - agg = ForestAdminDatasourceToolkit::Components::Query::Aggregation.new(operation: 'Count', - groups: [{ field: 'id' }]) - expect { collection.aggregate(nil, filter, agg) } - .to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + it 'declares the collection non-countable so Forest never requests a count' do + expect(collection.schema[:countable]).to be(false) 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 index 1570ed8a5..9bd667816 100644 --- 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 @@ -165,13 +165,5 @@ module ForestAdminDatasourceMambuPayments expect(client).not_to have_received(:list_claims) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_claims).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index c9891fa87..489747074 100644 --- 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 @@ -93,13 +93,5 @@ module ForestAdminDatasourceMambuPayments expect(collection.list(nil, filter, nil)).to eq([]) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_connected_accounts).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index e4c610f42..931b70fde 100644 --- 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 @@ -180,13 +180,5 @@ module ForestAdminDatasourceMambuPayments expect(rows.first).to eq('id' => 'ev1', 'status' => 'delivered', 'topic' => 'payment_order') end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_events).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index 972b52600..bfe28fa72 100644 --- 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 @@ -112,13 +112,5 @@ module ForestAdminDatasourceMambuPayments ) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_files).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index 02b617101..6d91c87e7 100644 --- 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 @@ -128,13 +128,5 @@ module ForestAdminDatasourceMambuPayments expect(rows.first).to eq('id' => 'ip1', 'status' => 'received', 'amount' => 12_500) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_incoming_payments).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index cdc779371..2b27f5602 100644 --- 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 @@ -163,13 +163,5 @@ module ForestAdminDatasourceMambuPayments expect(client).not_to have_received(:list_payee_verification_requests) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_payee_verification_requests).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index 2ee2740da..46a6e7499 100644 --- 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 @@ -183,13 +183,5 @@ module ForestAdminDatasourceMambuPayments expect(client).not_to have_received(:list_payment_captures) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_payment_captures).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - 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 index fc21d7843..45edb74d3 100644 --- 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 @@ -158,14 +158,6 @@ module ForestAdminDatasourceMambuPayments end end - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_reconciliations).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - end - end - describe '#create' do it 'POSTs the payload stripping system-managed fields' do allow(client).to receive(:create_reconciliation) do |payload| 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 index 20cae3263..445b9f8e9 100644 --- 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 @@ -162,14 +162,6 @@ module ForestAdminDatasourceMambuPayments end end - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_returns).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - end - end - describe '#create' do it 'POSTs the payload stripping system-managed fields' do allow(client).to receive(:create_return) do |payload| 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 index f0549da61..45ef323fd 100644 --- 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 @@ -109,13 +109,5 @@ module ForestAdminDatasourceMambuPayments expect(rows.first).to eq('id' => 'tx1', 'category' => 'direct_debit', 'amount' => 5000) end end - - describe '#aggregate Count' do - it 'counts via the server-side total' do - allow(client).to receive(:count_transactions).and_return(2) - result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) - expect(result.first['value']).to eq(2) - end - end end end From 637d001fd7a0777da8b7f6f65de838a928e40566 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Fri, 12 Jun 2026 12:23:20 +0200 Subject: [PATCH 23/24] refactor(mambu_payments): dedup link plugins, cut complexity Address qlty.sh maintainability findings (no behaviour change). Deduplication (Tier 1): - OneToManyLinkPlugin base for the 8 simple reciprocal links. - TwoStepLinkPlugin base for the 4 computed-FK pivot links. - HolderLinkPlugin base for the 3 imported-FK account-holder links. Each concrete plugin is now a short declarative subclass. Complexity (Tier 2): - Split paginate into effective_limit / fetch_window / cursor_params. - Collapse embed_many_to_one's keyword args into an Embed struct (drops the ParameterLists disable); fetch_by_ids is now public so a collection can resolve another's records when embedding. - Extract fallback_records and join_errors in the client. Specs: 482 examples, 0 failures; rubocop clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client.rb | 29 ++--- .../collections/base_collection.rb | 104 +++++++++--------- .../collections/event.rb | 2 +- .../plugins/relations/holder_link_plugin.rb | 56 ++++++++++ ...account_holder_to_direct_debit_mandates.rb | 46 ++------ ...ink_account_holder_to_incoming_payments.rb | 46 ++------ ...ternal_account_to_direct_debit_mandates.rb | 23 +--- ...k_external_account_to_incoming_payments.rb | 23 +--- ...link_external_account_to_payment_orders.rb | 25 +---- .../link_incoming_payment_to_events.rb | 32 +----- ...k_incoming_payment_to_expected_payments.rb | 60 ++-------- .../link_incoming_payment_to_returns.rb | 30 +---- .../link_incoming_payment_to_transactions.rb | 54 ++------- .../link_internal_account_to_balances.rb | 47 ++------ ...k_internal_account_to_incoming_payments.rb | 23 +--- ...link_internal_account_to_payment_orders.rb | 47 ++------ .../relations/link_payment_order_to_events.rb | 32 +----- ...yment_order_to_receiving_account_holder.rb | 50 ++------- .../link_payment_order_to_returns.rb | 30 +---- .../relations/one_to_many_link_plugin.rb | 35 ++++++ .../plugins/relations/two_step_link_plugin.rb | 64 +++++++++++ .../collections/base_collection_spec.rb | 50 ++++----- 22 files changed, 348 insertions(+), 560 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/holder_link_plugin.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/one_to_many_link_plugin.rb create mode 100644 packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_link_plugin.rb 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 index f2acf2650..fa56391bc 100644 --- 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 @@ -58,18 +58,20 @@ def extract_records(body, path) return [] unless body.is_a?(Hash) wrapped = body['data'] || body[path] || body['records'] || body['items'] - return wrapped if wrapped.is_a?(Array) + 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) } - if fallback - ForestAdminDatasourceMambuPayments.logger.warn( - "[forest_admin_datasource_mambu_payments] list(#{path}) used wrapper-key fallback; " \ - "body keys=#{body.keys.inspect}" - ) - return fallback - end + 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) @@ -120,13 +122,14 @@ def error_detail(status, body) def error_message(parsed) return parsed.to_s[0, 500] unless parsed.is_a?(Hash) - message = parsed.dig('error', 'message') || parsed['message'] || parsed['detail'] - message ||= Array(parsed['errors']).filter_map do |e| - e.is_a?(Hash) ? (e['message'] || e['detail']) : e - end.join('; ') + 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? 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 index 5b8639499..43aad1617 100644 --- 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 @@ -1,17 +1,10 @@ module ForestAdminDatasourceMambuPayments module Collections - # Shared behaviour for every Numeral-backed collection. - # - # Subclasses declare their REST resource once with `client_resource` and - # implement `serialize`. The read path (list / count / id-lookup / - # pagination / relation embedding) lives here so a fix lands in one place - # rather than being copy-pasted across ~17 collections. - # - # Filtering contract: `collection_filters` lists the server-filterable - # fields (merged with the always-present `id`). `reconcile_filter_operators!` - # then narrows each column's advertised `filter_operators` to exactly what - # the Numeral API can serve, so the UI never offers a filter that would - # raise at query time. + # 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 @@ -38,9 +31,8 @@ class << self attr_accessor :resource_singular, :resource_plural end - # Declares the Numeral REST resource backing this collection, wiring the - # generic read path to the matching `list_*` / `count_*` / `find_*` - # client methods. + # 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 @@ -63,6 +55,14 @@ def aggregate(_caller, _filter, _aggregation, _limit = nil) '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 @@ -107,41 +107,40 @@ def fetch_records(filter) paginate(filter.page, translate_filters(filter.condition_tree)) end - # Resolves a set of ids via the per-id `find_*` endpoint. Numeral has no - # `id`/`ids` list filter, so there is no batch fetch — we de-duplicate and - # fetch each distinct id once (one `GET /resource/:id` per id). - def fetch_by_ids(ids) - ids = Array(ids).reject { |id| id.to_s.empty? }.uniq - return [] if ids.empty? + # 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 - ids.filter_map { |id| client_find(id) } + def effective_limit(page) + limit = page&.limit + limit.nil? || limit <= 0 ? Client::MAX_PER_PAGE : limit end - # Numeral paginates with a `starting_after` cursor (the id of the last seen - # record) plus a `limit` capped at MAX_PER_PAGE — it has no offset/page - # parameter. Forest, however, asks for an [offset, offset + limit) window, - # so we walk forward in cursor pages until the window is covered, then - # slice. The first page of a small list is a single request. - def paginate(page, params) - offset = page&.offset.to_i - limit = page&.limit - limit = Client::MAX_PER_PAGE if limit.nil? || limit <= 0 + # 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 - page_params = params.merge(limit: chunk) - page_params[:starting_after] = cursor if cursor - batch = client_list(**page_params) + 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[offset, limit] || [] + 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) @@ -199,6 +198,11 @@ 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. @@ -207,35 +211,31 @@ def embed_relations(rows, records, projection) sources = records.map { |r| attrs_of(r) } many_to_one_embeds.each do |embed| - target = datasource.get_collection(embed[:collection]) - embed_many_to_one( - rows, sources, projection, - foreign_key: embed[:foreign_key], relation_name: embed[:relation_name], - batch_fetcher: ->(ids) { target.send(:fetch_by_ids, ids) }, - serializer: ->(raw) { target.serialize(raw) } - ) + 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 call and writes the serialized record back onto each row. - # rubocop:disable Metrics/ParameterLists - def embed_many_to_one(rows, sources, projection, foreign_key:, relation_name:, batch_fetcher:, serializer:) - return unless relations_in(projection).include?(relation_name) + # 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[foreign_key] }.reject { |id| id.to_s.empty? }.uniq + ids = sources.filter_map { |s| s[embed.foreign_key] }.reject { |id| id.to_s.empty? }.uniq return if ids.empty? - by_id = batch_fetcher.call(ids).to_h { |raw| [attrs_of(raw)['id'], raw] } + 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][foreign_key] + fk_value = sources[i][embed.foreign_key] next if fk_value.to_s.empty? raw = by_id[fk_value] - row[relation_name] = raw && serializer.call(raw) + row[embed.relation_name] = raw && embed.resolver.serialize(raw) end end - # rubocop:enable Metrics/ParameterLists # 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 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 index 6301a204e..104e17019 100644 --- 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 @@ -95,7 +95,7 @@ def build_related_object_caches(sources) ids_by_collection.to_h do |collection_name, ids| target = datasource.get_collection(collection_name) - by_id = target.send(:fetch_by_ids, ids).to_h do |raw| + by_id = target.fetch_by_ids(ids).to_h do |raw| [attrs_of(raw)['id'], target.serialize(raw)] end [collection_name, by_id] 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 index 07d42a614..3dd443237 100644 --- 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 @@ -1,45 +1,13 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable AccountHolder <-> DirectDebitMandate link. - # The chain is transitive: DDM -> external_account -> account_holder. - # See TwoStepHolderFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToDirectDebitMandates, - # {} - # ) - class LinkAccountHolderToDirectDebitMandates < ForestAdminDatasourceCustomizer::Plugins::Plugin - DIRECT_DEBIT_MANDATE = 'MambuDirectDebitMandate'.freeze - EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze - ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze - FK_NAME = 'account_holder_id'.freeze - LOCAL_FK = 'external_account_id'.freeze - IMPORT_PATH = 'external_account:account_holder_id'.freeze - MANY_TO_ONE_NAME = 'account_holder'.freeze - ONE_TO_MANY_NAME = 'direct_debit_mandates'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(DIRECT_DEBIT_MANDATE) do |c| - c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) - c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, - foreign_key: FK_NAME, - foreign_key_target: 'id') - TwoStepHolderFilter.install(c, - fk_name: FK_NAME, - local_fk: LOCAL_FK, - intermediate_collection: EXTERNAL_ACCOUNT) - end - - datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, DIRECT_DEBIT_MANDATE, - origin_key: FK_NAME, - origin_key_target: 'id') - end - end + # 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 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 index d3a17a3da..91f6f2f81 100644 --- 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 @@ -1,45 +1,13 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable AccountHolder <-> IncomingPayment link. - # The chain is transitive: IP -> internal_account -> account_holder. - # See TwoStepHolderFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkAccountHolderToIncomingPayments, - # {} - # ) - class LinkAccountHolderToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze - ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze - FK_NAME = 'account_holder_id'.freeze - LOCAL_FK = 'internal_account_id'.freeze - IMPORT_PATH = 'internal_account:account_holder_id'.freeze - MANY_TO_ONE_NAME = 'account_holder'.freeze - ONE_TO_MANY_NAME = 'incoming_payments'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| - c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) - c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, - foreign_key: FK_NAME, - foreign_key_target: 'id') - TwoStepHolderFilter.install(c, - fk_name: FK_NAME, - local_fk: LOCAL_FK, - intermediate_collection: INTERNAL_ACCOUNT) - end - - datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, INCOMING_PAYMENT, - origin_key: FK_NAME, - origin_key_target: 'id') - end - end + # 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 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 index 728b05ba9..7f798a0e1 100644 --- 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 @@ -3,25 +3,10 @@ module Plugins module Relations # Reciprocal OneToMany on MambuExternalAccount for the native # DirectDebitMandate.external_account ManyToOne. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToDirectDebitMandates, - # {} - # ) - class LinkExternalAccountToDirectDebitMandates < ForestAdminDatasourceCustomizer::Plugins::Plugin - EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze - DIRECT_DEBIT_MANDATE = 'MambuDirectDebitMandate'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation('direct_debit_mandates', DIRECT_DEBIT_MANDATE, - origin_key: 'external_account_id', - origin_key_target: 'id') - end - end + # 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 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 index 36c1ef0c8..0c7d9d0f4 100644 --- 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 @@ -3,25 +3,10 @@ module Plugins module Relations # Reciprocal OneToMany on MambuExternalAccount for the native # IncomingPayment.external_account ManyToOne. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToIncomingPayments, - # {} - # ) - class LinkExternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin - EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, - origin_key: 'external_account_id', - origin_key_target: 'id') - end - end + # 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 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 index 94764842d..6d1e22b41 100644 --- 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 @@ -2,26 +2,11 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations # Reciprocal OneToMany on MambuExternalAccount for the native - # PaymentOrder.external_account ManyToOne (FK: receiving_account_id). - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkExternalAccountToPaymentOrders, - # {} - # ) - class LinkExternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plugins::Plugin - EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze - PAYMENT_ORDER = 'MambuPaymentOrder'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(EXTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation('payment_orders', PAYMENT_ORDER, - origin_key: 'receiving_account_id', - origin_key_target: 'id') - end - end + # 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 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 index c4f6ee85e..223557bac 100644 --- 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 @@ -1,32 +1,12 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # OneToMany on MambuIncomingPayment for Event.related_object_id. - # Event.related_object_id is polymorphic (incoming_payment, payment_order, - # transaction, ...), but UUIDs are globally unique so filtering by id - # alone yields exactly the events about the given IP. - # - # Requires Event.api_filters to expose `related_object_id` — declared in - # the Event collection itself. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToEvents, - # {} - # ) - class LinkIncomingPaymentToEvents < ForestAdminDatasourceCustomizer::Plugins::Plugin - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - EVENT = 'MambuEvent'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| - c.add_one_to_many_relation('events', EVENT, - origin_key: 'related_object_id', - origin_key_target: 'id') - end - end + # 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 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 index d9fa524b6..a6941b252 100644 --- 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 @@ -1,55 +1,19 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable IncomingPayment <-> ExpectedPayment link. - # The chain crosses MambuReconciliation twice via the shared transaction: - # IP -> Reconciliation(incoming_payment) -> Transaction - # -> Reconciliation(expected_payment) -> ExpectedPayment - # Named `matched_expected_payments` on the IP side to make the transitive - # (reconciliation-driven) nature explicit — it is not a native FK. - # See TwoStepCrossReconciliationFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToExpectedPayments, - # {} - # ) - class LinkIncomingPaymentToExpectedPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin - ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + # 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' - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - EXPECTED_PAYMENT = 'MambuExpectedPayment'.freeze - FK_NAME = 'incoming_payment_id'.freeze - SRC_PAYMENT_TYPE = 'incoming_payment'.freeze - DST_PAYMENT_TYPE = 'expected_payment'.freeze - ONE_TO_MANY_NAME = 'matched_expected_payments'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(EXPECTED_PAYMENT) do |c| - # Virtual column: ExpectedPayment has no native incoming_payment_id. - # The link goes through two reconciliations sharing a transaction; - # populating a per-record value would require scanning all - # reconciliations. Kept nil; only EQUAL/IN filters are rewritten - # via the TwoStepCrossReconciliationFilter below. - c.add_field(FK_NAME, ComputedDefinition.new( - column_type: 'String', - dependencies: ['id'], - values: proc { |records, _ctx| records.map { nil } } - )) - TwoStepCrossReconciliationFilter.install(c, - fk_name: FK_NAME, - src_payment_type: SRC_PAYMENT_TYPE, - dst_payment_type: DST_PAYMENT_TYPE, - target_field: 'id') - end - - datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, EXPECTED_PAYMENT, - origin_key: FK_NAME, - origin_key_target: 'id') - end + 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 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 index fc5f27ea6..993881a92 100644 --- 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 @@ -1,31 +1,11 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # OneToMany on MambuIncomingPayment for Return.related_payment_id. - # Return.related_payment_id is polymorphic (payment_order or - # incoming_payment), but UUIDs are globally unique so filtering by id - # alone yields exactly the returns belonging to the given IP. The same - # column is also exposed as `returns` on MambuPaymentOrder; the two - # relations are independent because the underlying ids are disjoint. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkIncomingPaymentToReturns, - # {} - # ) - class LinkIncomingPaymentToReturns < ForestAdminDatasourceCustomizer::Plugins::Plugin - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - RETURN_COLL = 'MambuReturn'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(INCOMING_PAYMENT) do |c| - c.add_one_to_many_relation('returns', RETURN_COLL, - origin_key: 'related_payment_id', - origin_key_target: 'id') - end - end + # 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 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 index 63b2caf2f..c3826022c 100644 --- 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 @@ -1,50 +1,18 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable IncomingPayment <-> Transaction link. - # Transaction has no native incoming_payment_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::LinkIncomingPaymentToTransactions, - # {} - # ) - class LinkIncomingPaymentToTransactions < ForestAdminDatasourceCustomizer::Plugins::Plugin - ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + # 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' - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - TRANSACTION = 'MambuTransaction'.freeze - FK_NAME = 'incoming_payment_id'.freeze - PAYMENT_TYPE = 'incoming_payment'.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 incoming_payment_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(INCOMING_PAYMENT) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, TRANSACTION, - origin_key: FK_NAME, - origin_key_target: 'id') - end + def install_source_filter(collection) + TwoStepReconciliationFilter.install(collection, + fk_name: 'incoming_payment_id', + payment_type: 'incoming_payment', + target_field: 'id') 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 index 3333d66f8..158c86e72 100644 --- 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 @@ -1,46 +1,15 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable InternalAccount <-> Balance link. - # The chain is transitive: Balance.connected_account_id is matched - # against the InternalAccount.connected_account_ids array. - # See TwoStepConnectedAccountFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToBalances, - # {} - # ) - class LinkInternalAccountToBalances < ForestAdminDatasourceCustomizer::Plugins::Plugin - ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + # 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' - BALANCE = 'MambuBalance'.freeze - INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze - FK_NAME = 'internal_account_id'.freeze - LOCAL_FK = 'connected_account_id'.freeze - ONE_TO_MANY_NAME = 'balances'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(BALANCE) do |c| - # Virtual column: Balance has no native internal_account_id. - # The value is nil per record (reverse lookup would require scanning - # all internal accounts) — only EQUAL/IN are rewritten via the - # TwoStepConnectedAccountFilter below. - c.add_field(FK_NAME, ComputedDefinition.new( - column_type: 'String', - dependencies: ['id'], - values: proc { |records, _ctx| records.map { nil } } - )) - TwoStepConnectedAccountFilter.install(c, target_field: LOCAL_FK) - end - - datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, BALANCE, - origin_key: FK_NAME, - origin_key_target: 'id') - end + def install_source_filter(collection) + TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id') 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 index d7778d24e..7fecca761 100644 --- 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 @@ -3,25 +3,10 @@ module Plugins module Relations # Reciprocal OneToMany on MambuInternalAccount for the native # IncomingPayment.internal_account ManyToOne. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToIncomingPayments, - # {} - # ) - class LinkInternalAccountToIncomingPayments < ForestAdminDatasourceCustomizer::Plugins::Plugin - INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze - INCOMING_PAYMENT = 'MambuIncomingPayment'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation('incoming_payments', INCOMING_PAYMENT, - origin_key: 'internal_account_id', - origin_key_target: 'id') - end - end + # 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 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 index 1fe181178..2846ddf98 100644 --- 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 @@ -1,46 +1,15 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable InternalAccount <-> PaymentOrder link. - # The chain is transitive: PO.connected_account_id is matched against - # the InternalAccount.connected_account_ids array. - # See TwoStepConnectedAccountFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkInternalAccountToPaymentOrders, - # {} - # ) - class LinkInternalAccountToPaymentOrders < ForestAdminDatasourceCustomizer::Plugins::Plugin - ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition + # 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' - PAYMENT_ORDER = 'MambuPaymentOrder'.freeze - INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze - FK_NAME = 'internal_account_id'.freeze - LOCAL_FK = 'connected_account_id'.freeze - ONE_TO_MANY_NAME = 'payment_orders'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| - # Virtual column: PaymentOrder has no native internal_account_id. - # The value is nil per record (reverse lookup would require scanning - # all internal accounts) — only EQUAL/IN are rewritten via the - # TwoStepConnectedAccountFilter below. - c.add_field(FK_NAME, ComputedDefinition.new( - column_type: 'String', - dependencies: ['id'], - values: proc { |records, _ctx| records.map { nil } } - )) - TwoStepConnectedAccountFilter.install(c, target_field: LOCAL_FK) - end - - datasource_customizer.customize_collection(INTERNAL_ACCOUNT) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, PAYMENT_ORDER, - origin_key: FK_NAME, - origin_key_target: 'id') - end + def install_source_filter(collection) + TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id') 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 index 2fd8591fe..c142a8fed 100644 --- 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 @@ -1,32 +1,12 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # OneToMany on MambuPaymentOrder for Event.related_object_id. - # Event.related_object_id is polymorphic (payment_order, transaction, - # incoming_payment, ...), but UUIDs are globally unique so filtering by - # id alone yields exactly the events about the given PO. - # - # Requires Event.api_filters to expose `related_object_id` — added in - # the Event collection itself. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToEvents, - # {} - # ) - class LinkPaymentOrderToEvents < ForestAdminDatasourceCustomizer::Plugins::Plugin - PAYMENT_ORDER = 'MambuPaymentOrder'.freeze - EVENT = 'MambuEvent'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| - c.add_one_to_many_relation('events', EVENT, - origin_key: 'related_object_id', - origin_key_target: 'id') - end - end + # 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 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 index 5640cce3c..d216265ee 100644 --- 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 @@ -1,48 +1,14 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # Exposes a navigable PaymentOrder <-> AccountHolder link. - # The chain is transitive: PO.receiving_account_id -> ExternalAccount.account_holder_id. - # Named `receiving_account_holder` rather than `account_holder` to make it - # explicit that this is the counterparty (receiving) account's holder, - # not the holder of our own (internal) side of the order. - # See TwoStepHolderFilter for the OneToMany filter rewrite. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReceivingAccountHolder, - # {} - # ) - class LinkPaymentOrderToReceivingAccountHolder < ForestAdminDatasourceCustomizer::Plugins::Plugin - PAYMENT_ORDER = 'MambuPaymentOrder'.freeze - EXTERNAL_ACCOUNT = 'MambuExternalAccount'.freeze - ACCOUNT_HOLDER = 'MambuAccountHolder'.freeze - FK_NAME = 'account_holder_id'.freeze - LOCAL_FK = 'receiving_account_id'.freeze - IMPORT_PATH = 'external_account:account_holder_id'.freeze - MANY_TO_ONE_NAME = 'receiving_account_holder'.freeze - ONE_TO_MANY_NAME = 'payment_orders'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| - c.import_field(FK_NAME, path: IMPORT_PATH, readonly: true) - c.add_many_to_one_relation(MANY_TO_ONE_NAME, ACCOUNT_HOLDER, - foreign_key: FK_NAME, - foreign_key_target: 'id') - TwoStepHolderFilter.install(c, - fk_name: FK_NAME, - local_fk: LOCAL_FK, - intermediate_collection: EXTERNAL_ACCOUNT) - end - - datasource_customizer.customize_collection(ACCOUNT_HOLDER) do |c| - c.add_one_to_many_relation(ONE_TO_MANY_NAME, PAYMENT_ORDER, - origin_key: FK_NAME, - origin_key_target: 'id') - end - end + # 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 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 index 8c2a5219a..fd2326bf9 100644 --- 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 @@ -1,31 +1,11 @@ module ForestAdminDatasourceMambuPayments module Plugins module Relations - # OneToMany on MambuPaymentOrder for Return.related_payment_id. - # Return.related_payment_id is polymorphic (payment_order or - # incoming_payment), but UUIDs are globally unique so filtering by id - # alone yields exactly the returns belonging to the given PO. The same - # column can later be used to expose `returns` on MambuIncomingPayment - # without conflict. - # - # Install at the datasource level: - # @agent.use( - # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToReturns, - # {} - # ) - class LinkPaymentOrderToReturns < ForestAdminDatasourceCustomizer::Plugins::Plugin - PAYMENT_ORDER = 'MambuPaymentOrder'.freeze - RETURN_COLL = 'MambuReturn'.freeze - - def run(datasource_customizer, _collection_customizer = nil, _options = {}) - Plugins::Helpers.require_datasource!(datasource_customizer, self.class) - - datasource_customizer.customize_collection(PAYMENT_ORDER) do |c| - c.add_one_to_many_relation('returns', RETURN_COLL, - origin_key: 'related_payment_id', - origin_key_target: 'id') - end - end + # 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 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/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/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 index f3c0a4e42..0963d2da8 100644 --- 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 @@ -135,58 +135,56 @@ module ForestAdminDatasourceMambuPayments { 'connected_account_id' => '' } # blank ignored ] end - let(:batch_fetcher) { instance_double(Proc) } - let(:serializer) { ->(raw) { { 'id' => raw['id'], 'name' => raw['name'] } } } + 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(batch_fetcher).to receive(:call).with(['a']).and_return([{ 'id' => 'a', 'name' => 'Acme' }]) + allow(resolver).to receive(:fetch_by_ids).with(['a']).and_return([{ 'id' => 'a', 'name' => 'Acme' }]) - collection.send(:embed_many_to_one, rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - batch_fetcher: batch_fetcher, serializer: serializer) + 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(batch_fetcher).to have_received(:call).with(['a']).once + 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(batch_fetcher).to receive(:call) + allow(resolver).to receive(:fetch_by_ids) - collection.send(:embed_many_to_one, rows, sources, ['id'], - foreign_key: 'connected_account_id', relation_name: 'connected_account', - batch_fetcher: batch_fetcher, serializer: serializer) + collection.send(:embed_many_to_one, rows, sources, ['id'], embed) expect(rows).to all(satisfy { |r| !r.key?('connected_account') }) - expect(batch_fetcher).not_to have_received(:call) + expect(resolver).not_to have_received(:fetch_by_ids) end it 'does nothing when projection is nil' do - allow(batch_fetcher).to receive(:call) - collection.send(:embed_many_to_one, rows, sources, nil, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - batch_fetcher: batch_fetcher, serializer: serializer) - expect(batch_fetcher).not_to have_received(:call) + 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(batch_fetcher).to receive(:call) + 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, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - batch_fetcher: batch_fetcher, serializer: serializer) + collection.send(:embed_many_to_one, [{ 'id' => 'a' }, { 'id' => 'b' }], empty_sources, projection, embed) - expect(batch_fetcher).not_to have_received(:call) + 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(batch_fetcher).to receive(:call).with(['a']).and_return([]) + allow(resolver).to receive(:fetch_by_ids).with(['a']).and_return([]) - collection.send(:embed_many_to_one, rows, sources, projection, - foreign_key: 'connected_account_id', relation_name: 'connected_account', - batch_fetcher: batch_fetcher, serializer: serializer) + 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 From cc991741bd480cb7e4b242a1259ebefcfaf51afc Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Fri, 12 Jun 2026 14:38:57 +0200 Subject: [PATCH 24/24] ci(mambu_payments): wire datasource into CI and release - Add the package to the lint, test and coverage matrices in build.yml. - Add a Gemfile-test (path deps on toolkit/customizer) like the other datasources so the test job can run it. - Bump/build/push the gem and commit its version.rb on release (.releaserc.js prepareCmd, successCmd and git assets). - version.rb to double quotes (matches the release sed) and exclude it from Style/StringLiterals like the sibling packages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 4 +++- .releaserc.js | 7 +++++-- .rubocop.yml | 1 + .../Gemfile-test | 19 +++++++++++++++++++ .../version.rb | 2 +- 5 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/forest_admin_datasource_mambu_payments/Gemfile-test 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 7292621cf..ac26801c2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -212,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). 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/lib/forest_admin_datasource_mambu_payments/version.rb b/packages/forest_admin_datasource_mambu_payments/lib/forest_admin_datasource_mambu_payments/version.rb index 4bcb0ed6d..a45254c1e 100644 --- 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 @@ -1,3 +1,3 @@ module ForestAdminDatasourceMambuPayments - VERSION = '0.1.0' + VERSION = "0.1.0" end