forest_admin_datasource_mambu_payments 1.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/Rakefile +6 -0
  4. data/forest_admin_datasource_mambu_payments.gemspec +37 -0
  5. data/lib/forest_admin_datasource_mambu_payments/client/reads.rb +66 -0
  6. data/lib/forest_admin_datasource_mambu_payments/client/writes.rb +42 -0
  7. data/lib/forest_admin_datasource_mambu_payments/client.rb +166 -0
  8. data/lib/forest_admin_datasource_mambu_payments/collections/account_holder.rb +64 -0
  9. data/lib/forest_admin_datasource_mambu_payments/collections/balance.rb +75 -0
  10. data/lib/forest_admin_datasource_mambu_payments/collections/base_collection.rb +254 -0
  11. data/lib/forest_admin_datasource_mambu_payments/collections/claim.rb +98 -0
  12. data/lib/forest_admin_datasource_mambu_payments/collections/connected_account.rb +103 -0
  13. data/lib/forest_admin_datasource_mambu_payments/collections/direct_debit_mandate.rb +125 -0
  14. data/lib/forest_admin_datasource_mambu_payments/collections/event.rb +133 -0
  15. data/lib/forest_admin_datasource_mambu_payments/collections/expected_payment.rb +132 -0
  16. data/lib/forest_admin_datasource_mambu_payments/collections/external_account.rb +121 -0
  17. data/lib/forest_admin_datasource_mambu_payments/collections/file.rb +89 -0
  18. data/lib/forest_admin_datasource_mambu_payments/collections/incoming_payment.rb +120 -0
  19. data/lib/forest_admin_datasource_mambu_payments/collections/internal_account.rb +136 -0
  20. data/lib/forest_admin_datasource_mambu_payments/collections/payee_verification_request.rb +88 -0
  21. data/lib/forest_admin_datasource_mambu_payments/collections/payment_capture.rb +136 -0
  22. data/lib/forest_admin_datasource_mambu_payments/collections/payment_order.rb +132 -0
  23. data/lib/forest_admin_datasource_mambu_payments/collections/reconciliation.rb +93 -0
  24. data/lib/forest_admin_datasource_mambu_payments/collections/return.rb +132 -0
  25. data/lib/forest_admin_datasource_mambu_payments/collections/transaction.rb +113 -0
  26. data/lib/forest_admin_datasource_mambu_payments/configuration.rb +36 -0
  27. data/lib/forest_admin_datasource_mambu_payments/datasource.rb +35 -0
  28. data/lib/forest_admin_datasource_mambu_payments/plugins/disable_search.rb +31 -0
  29. data/lib/forest_admin_datasource_mambu_payments/plugins/helpers.rb +94 -0
  30. data/lib/forest_admin_datasource_mambu_payments/plugins/messages.rb +30 -0
  31. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/holder_link_plugin.rb +56 -0
  32. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_direct_debit_mandates.rb +14 -0
  33. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_account_holder_to_incoming_payments.rb +14 -0
  34. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_direct_debit_mandates.rb +13 -0
  35. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_incoming_payments.rb +13 -0
  36. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_external_account_to_payment_orders.rb +13 -0
  37. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_events.rb +13 -0
  38. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_expected_payments.rb +21 -0
  39. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_returns.rb +12 -0
  40. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_incoming_payment_to_transactions.rb +20 -0
  41. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_balances.rb +17 -0
  42. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_incoming_payments.rb +13 -0
  43. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_internal_account_to_payment_orders.rb +17 -0
  44. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_events.rb +13 -0
  45. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_receiving_account_holder.rb +15 -0
  46. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_returns.rb +12 -0
  47. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/link_payment_order_to_transactions.rb +51 -0
  48. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/one_to_many_link_plugin.rb +35 -0
  49. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/pivot_resolution.rb +73 -0
  50. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_connected_account_filter.rb +38 -0
  51. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_cross_reconciliation_filter.rb +55 -0
  52. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_holder_filter.rb +32 -0
  53. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_link_plugin.rb +64 -0
  54. data/lib/forest_admin_datasource_mambu_payments/plugins/relations/two_step_reconciliation_filter.rb +39 -0
  55. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/approve_payment_order.rb +56 -0
  56. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/cancel_payment_order.rb +66 -0
  57. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_account_holder.rb +44 -0
  58. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_external_account.rb +54 -0
  59. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_internal_account.rb +58 -0
  60. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/create_payment_order.rb +66 -0
  61. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/trigger_payee_verification.rb +58 -0
  62. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_account_holder.rb +67 -0
  63. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_external_account.rb +75 -0
  64. data/lib/forest_admin_datasource_mambu_payments/plugins/smart_actions/update_internal_account.rb +75 -0
  65. data/lib/forest_admin_datasource_mambu_payments/query/condition_tree_translator.rb +115 -0
  66. data/lib/forest_admin_datasource_mambu_payments/version.rb +3 -0
  67. data/lib/forest_admin_datasource_mambu_payments.rb +44 -0
  68. metadata +170 -0
@@ -0,0 +1,20 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # IncomingPayment <-> Transaction through the Reconciliation pivot
5
+ # (Reconciliation.payment_id + payment_type = incoming_payment).
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkIncomingPaymentToTransactions < TwoStepLinkPlugin
8
+ link owner: 'MambuIncomingPayment', filtered: 'MambuTransaction',
9
+ name: 'transactions', foreign_key: 'incoming_payment_id'
10
+
11
+ def install_source_filter(collection)
12
+ TwoStepReconciliationFilter.install(collection,
13
+ fk_name: 'incoming_payment_id',
14
+ payment_type: 'incoming_payment',
15
+ target_field: 'id')
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # InternalAccount <-> Balance, transitive via
5
+ # InternalAccount.connected_account_ids vs Balance.connected_account_id.
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkInternalAccountToBalances < TwoStepLinkPlugin
8
+ link owner: 'MambuInternalAccount', filtered: 'MambuBalance',
9
+ name: 'balances', foreign_key: 'internal_account_id'
10
+
11
+ def install_source_filter(collection)
12
+ TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id')
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Reciprocal OneToMany on MambuInternalAccount for the native
5
+ # IncomingPayment.internal_account ManyToOne.
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkInternalAccountToIncomingPayments < OneToManyLinkPlugin
8
+ link host: 'MambuInternalAccount', to: 'MambuIncomingPayment',
9
+ name: 'incoming_payments', origin_key: 'internal_account_id'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # InternalAccount <-> PaymentOrder, transitive via
5
+ # InternalAccount.connected_account_ids vs PaymentOrder.connected_account_id.
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkInternalAccountToPaymentOrders < TwoStepLinkPlugin
8
+ link owner: 'MambuInternalAccount', filtered: 'MambuPaymentOrder',
9
+ name: 'payment_orders', foreign_key: 'internal_account_id'
10
+
11
+ def install_source_filter(collection)
12
+ TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id')
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # OneToMany on MambuPaymentOrder over Event.related_object_id
5
+ # (events emitted for this payment order).
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkPaymentOrderToEvents < OneToManyLinkPlugin
8
+ link host: 'MambuPaymentOrder', to: 'MambuEvent',
9
+ name: 'events', origin_key: 'related_object_id'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # PaymentOrder <-> AccountHolder of the receiving external account.
5
+ # Named `receiving_account_holder` to disambiguate from the originating side.
6
+ # Install at the datasource level: @agent.use(plugin, {}).
7
+ class LinkPaymentOrderToReceivingAccountHolder < HolderLinkPlugin
8
+ link host: 'MambuPaymentOrder', name: 'payment_orders',
9
+ local_fk: 'receiving_account_id', intermediate: 'MambuExternalAccount',
10
+ import_path: 'external_account:account_holder_id',
11
+ many_to_one_name: 'receiving_account_holder'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # OneToMany on MambuPaymentOrder over Return.related_payment_id.
5
+ # Install at the datasource level: @agent.use(plugin, {}).
6
+ class LinkPaymentOrderToReturns < OneToManyLinkPlugin
7
+ link host: 'MambuPaymentOrder', to: 'MambuReturn',
8
+ name: 'returns', origin_key: 'related_payment_id'
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Exposes a navigable PaymentOrder <-> Transaction link.
5
+ # Transaction has no native payment_order_id; the relation is mediated by
6
+ # MambuReconciliation (Reconciliation.payment_id + payment_type discriminator).
7
+ # See TwoStepReconciliationFilter for the OneToMany filter rewrite.
8
+ #
9
+ # Install at the datasource level:
10
+ # @agent.use(
11
+ # ForestAdminDatasourceMambuPayments::Plugins::Relations::LinkPaymentOrderToTransactions,
12
+ # {}
13
+ # )
14
+ class LinkPaymentOrderToTransactions < ForestAdminDatasourceCustomizer::Plugins::Plugin
15
+ ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition
16
+
17
+ PAYMENT_ORDER = 'MambuPaymentOrder'.freeze
18
+ TRANSACTION = 'MambuTransaction'.freeze
19
+ FK_NAME = 'payment_order_id'.freeze
20
+ PAYMENT_TYPE = 'payment_order'.freeze
21
+ ONE_TO_MANY_NAME = 'transactions'.freeze
22
+
23
+ def run(datasource_customizer, _collection_customizer = nil, _options = {})
24
+ Plugins::Helpers.require_datasource!(datasource_customizer, self.class)
25
+
26
+ datasource_customizer.customize_collection(TRANSACTION) do |c|
27
+ # Virtual column: Transaction has no native payment_order_id.
28
+ # Reverse lookup would require scanning all reconciliations — kept
29
+ # nil per record; only EQUAL/IN filters are rewritten via the
30
+ # TwoStepReconciliationFilter below.
31
+ c.add_field(FK_NAME, ComputedDefinition.new(
32
+ column_type: 'String',
33
+ dependencies: ['id'],
34
+ values: proc { |records, _ctx| records.map { nil } }
35
+ ))
36
+ TwoStepReconciliationFilter.install(c,
37
+ fk_name: FK_NAME,
38
+ payment_type: PAYMENT_TYPE,
39
+ target_field: 'id')
40
+ end
41
+
42
+ datasource_customizer.customize_collection(PAYMENT_ORDER) do |c|
43
+ c.add_one_to_many_relation(ONE_TO_MANY_NAME, TRANSACTION,
44
+ origin_key: FK_NAME,
45
+ origin_key_target: 'id')
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Base for the "simple" reciprocal OneToMany links: the target already
5
+ # carries a native foreign key, so the plugin only declares the relation
6
+ # on the host. Subclasses configure it declaratively:
7
+ #
8
+ # class LinkExternalAccountToIncomingPayments < OneToManyLinkPlugin
9
+ # link host: 'MambuExternalAccount', to: 'MambuIncomingPayment',
10
+ # name: 'incoming_payments', origin_key: 'external_account_id'
11
+ # end
12
+ #
13
+ # Install at the datasource level: @agent.use(plugin, {}).
14
+ class OneToManyLinkPlugin < ForestAdminDatasourceCustomizer::Plugins::Plugin
15
+ class << self
16
+ attr_reader :config
17
+ end
18
+
19
+ def self.link(host:, to:, name:, origin_key:)
20
+ @config = { host: host, to: to, name: name, origin_key: origin_key }
21
+ end
22
+
23
+ def run(datasource_customizer, _collection_customizer = nil, _options = {})
24
+ Plugins::Helpers.require_datasource!(datasource_customizer, self.class)
25
+
26
+ cfg = self.class.config
27
+ datasource_customizer.customize_collection(cfg[:host]) do |c|
28
+ c.add_one_to_many_relation(cfg[:name], cfg[:to],
29
+ origin_key: cfg[:origin_key], origin_key_target: 'id')
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,73 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Shared machinery for the "two-step" relation filters. Forest's native
5
+ # OneToMany navigation emits a leaf on a virtual foreign key; these filters
6
+ # pre-resolve that key against an intermediate collection and rewrite the
7
+ # predicate as `target_field IN [resolved ids]`.
8
+ #
9
+ # Centralising it here keeps the four concrete filters in sync: the
10
+ # match-nothing sentinel, the EQUAL/IN normalisation, and — crucially —
11
+ # the *paginated* intermediate read all live in one place. A single
12
+ # un-paginated `list` would silently cap resolution at one API page and
13
+ # drop matching records for large relations.
14
+ module PivotResolution
15
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
16
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
17
+ ConditionTreeBranch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
18
+ Filter = ForestAdminDatasourceToolkit::Components::Query::Filter
19
+ Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
20
+ Page = ForestAdminDatasourceToolkit::Components::Query::Page
21
+
22
+ # All-zero UUID: guaranteed not to exist in Numeral, so the native list
23
+ # returns []. Expresses "match nothing" without tripping the empty-IN
24
+ # guard in ConditionTreeTranslator.
25
+ NO_MATCH_SENTINEL = '00000000-0000-0000-0000-000000000000'.freeze
26
+
27
+ # Only EQUAL/IN are rewritten — the operators Forest's OneToMany
28
+ # navigation actually emits.
29
+ SUPPORTED_OPS = [Operators::EQUAL, Operators::IN].freeze
30
+
31
+ # Upper bound on resolved ids. The host collection walks Numeral's cursor
32
+ # pages under the hood; we ask for one large window so it fetches them all
33
+ # in O(n / page) rather than re-walking per offset. A relation resolving
34
+ # to more than this is logged rather than silently truncated.
35
+ MAX_RESOLVED = 10_000
36
+
37
+ module_function
38
+
39
+ def normalize(value, operator)
40
+ values = operator == Operators::IN ? Array(value) : [value]
41
+ values.compact.reject { |v| v.to_s.empty? }.uniq
42
+ end
43
+
44
+ def no_match(target_field)
45
+ ConditionTreeLeaf.new(target_field, Operators::EQUAL, NO_MATCH_SENTINEL)
46
+ end
47
+
48
+ def and_branch(*leaves)
49
+ ConditionTreeBranch.new('And', leaves)
50
+ end
51
+
52
+ # Lists every row of `collection_name` matching `condition_tree` and
53
+ # returns the unique non-empty values of `field` (handles both scalar
54
+ # columns and array columns such as InternalAccount.connected_account_ids).
55
+ # One large-window request lets the collection's cursor pagination fetch
56
+ # all matching rows in a single forward walk.
57
+ def collect(context, collection_name, condition_tree, field)
58
+ rows = context.datasource.get_collection(collection_name).list(
59
+ Filter.new(condition_tree: condition_tree, page: Page.new(offset: 0, limit: MAX_RESOLVED)),
60
+ Projection.new([field])
61
+ )
62
+ if rows.size >= MAX_RESOLVED
63
+ ForestAdminDatasourceMambuPayments.logger.warn(
64
+ "[forest_admin_datasource_mambu_payments] #{collection_name} relation resolution hit the " \
65
+ "#{MAX_RESOLVED}-row cap on '#{field}'; results may be truncated."
66
+ )
67
+ end
68
+ rows.flat_map { |row| Array(row[field]) }.compact.reject { |v| v.to_s.empty? }.uniq
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Two-step pre-resolution for `internal_account_id` filters on host
5
+ # collections that link to InternalAccount transitively via the array
6
+ # column `InternalAccount.connected_account_ids` (not a scalar FK).
7
+ # Resolves the holder ids to the set of connected_account ids, then
8
+ # rewrites the predicate against a real field on the host collection
9
+ # (`id` for ConnectedAccount, `connected_account_id` for resources
10
+ # scoped by connected account).
11
+ module TwoStepConnectedAccountFilter
12
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
13
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
14
+
15
+ INTERNAL_ACCOUNT = 'MambuInternalAccount'.freeze
16
+ ARRAY_FIELD = 'connected_account_ids'.freeze
17
+ FK_NAME = 'internal_account_id'.freeze
18
+
19
+ def self.install(collection_customizer, target_field:)
20
+ PivotResolution::SUPPORTED_OPS.each do |operator|
21
+ collection_customizer.replace_field_operator(FK_NAME, operator) do |value, context|
22
+ ia_ids = PivotResolution.normalize(value, operator)
23
+ next PivotResolution.no_match(target_field) if ia_ids.empty?
24
+
25
+ ca_ids = PivotResolution.collect(
26
+ context, INTERNAL_ACCOUNT,
27
+ ConditionTreeLeaf.new('id', Operators::IN, ia_ids), ARRAY_FIELD
28
+ )
29
+ next PivotResolution.no_match(target_field) if ca_ids.empty?
30
+
31
+ ConditionTreeLeaf.new(target_field, Operators::IN, ca_ids)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,55 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Resolves "matched payment" filters that cross the Reconciliation pivot
5
+ # twice via the shared transaction. Used when two payment resources only
6
+ # know about each other through reconciliations against the same
7
+ # Transaction (e.g. IncomingPayment <-> ExpectedPayment).
8
+ #
9
+ # Chain (for a `matched_X_id` filter installed on host collection Y):
10
+ # Reconciliation WHERE payment_id IN [x_ids] AND payment_type = src
11
+ # -> transaction_ids
12
+ # Reconciliation WHERE transaction_id IN [tx_ids] AND payment_type = dst
13
+ # -> y_ids
14
+ # The predicate is then rewritten as `target_field IN y_ids` on the host.
15
+ module TwoStepCrossReconciliationFilter
16
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
17
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
18
+
19
+ RECONCILIATION = 'MambuReconciliation'.freeze
20
+
21
+ def self.install(collection_customizer, fk_name:, src_payment_type:, dst_payment_type:, target_field:)
22
+ PivotResolution::SUPPORTED_OPS.each do |operator|
23
+ collection_customizer.replace_field_operator(fk_name, operator) do |value, context|
24
+ src_ids = PivotResolution.normalize(value, operator)
25
+ next PivotResolution.no_match(target_field) if src_ids.empty?
26
+
27
+ tx_ids = TwoStepCrossReconciliationFilter.resolve(context, 'payment_id', src_ids,
28
+ src_payment_type, 'transaction_id')
29
+ next PivotResolution.no_match(target_field) if tx_ids.empty?
30
+
31
+ dst_ids = TwoStepCrossReconciliationFilter.resolve(context, 'transaction_id', tx_ids,
32
+ dst_payment_type, 'payment_id')
33
+ next PivotResolution.no_match(target_field) if dst_ids.empty?
34
+
35
+ ConditionTreeLeaf.new(target_field, Operators::IN, dst_ids)
36
+ end
37
+ end
38
+ end
39
+
40
+ # One hop across the reconciliation pivot: rows where `where_field IN ids`
41
+ # and `payment_type = type`, projected onto `select_field`.
42
+ def self.resolve(context, where_field, ids, payment_type, select_field)
43
+ PivotResolution.collect(
44
+ context, RECONCILIATION,
45
+ PivotResolution.and_branch(
46
+ ConditionTreeLeaf.new(where_field, Operators::IN, ids),
47
+ ConditionTreeLeaf.new('payment_type', Operators::EQUAL, payment_type)
48
+ ),
49
+ select_field
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Two-step pre-resolution for `account_holder_id` filters on host
5
+ # collections that link to AccountHolder transitively. Default
6
+ # `import_field` would emit a nested leaf the native list rejects;
7
+ # we pre-list the intermediate collection and rewrite as
8
+ # `local_fk IN (ids)`.
9
+ module TwoStepHolderFilter
10
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
11
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
12
+
13
+ def self.install(collection_customizer, fk_name:, local_fk:, intermediate_collection:)
14
+ PivotResolution::SUPPORTED_OPS.each do |operator|
15
+ collection_customizer.replace_field_operator(fk_name, operator) do |value, context|
16
+ holder_ids = PivotResolution.normalize(value, operator)
17
+ next PivotResolution.no_match(local_fk) if holder_ids.empty?
18
+
19
+ fk_ids = PivotResolution.collect(
20
+ context, intermediate_collection,
21
+ ConditionTreeLeaf.new(fk_name, Operators::IN, holder_ids), 'id'
22
+ )
23
+ next PivotResolution.no_match(local_fk) if fk_ids.empty?
24
+
25
+ ConditionTreeLeaf.new(local_fk, Operators::IN, fk_ids)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Base for relations whose foreign key is not native: the `filtered`
5
+ # collection gets a virtual (always-nil) FK column plus a two-step
6
+ # operator filter that rewrites EQUAL/IN predicates, and the `owner`
7
+ # collection gets the reciprocal OneToMany. Subclasses declare the shape
8
+ # and provide the concrete filter install:
9
+ #
10
+ # class LinkInternalAccountToBalances < TwoStepLinkPlugin
11
+ # link owner: 'MambuInternalAccount', filtered: 'MambuBalance',
12
+ # name: 'balances', fk: 'internal_account_id'
13
+ # def install_source_filter(collection)
14
+ # TwoStepConnectedAccountFilter.install(collection, target_field: 'connected_account_id')
15
+ # end
16
+ # end
17
+ #
18
+ # Install at the datasource level: @agent.use(plugin, {}).
19
+ class TwoStepLinkPlugin < ForestAdminDatasourceCustomizer::Plugins::Plugin
20
+ ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition
21
+
22
+ class << self
23
+ attr_reader :config
24
+ end
25
+
26
+ def self.link(owner:, filtered:, name:, foreign_key:)
27
+ @config = { owner: owner, filtered: filtered, name: name, fk: foreign_key }
28
+ end
29
+
30
+ # The virtual FK is nil per record: a reverse lookup would require
31
+ # scanning the pivot/intermediate collection. Only EQUAL/IN filters are
32
+ # meaningful, and those are rewritten by the source filter.
33
+ def self.virtual_fk
34
+ ComputedDefinition.new(
35
+ column_type: 'String',
36
+ dependencies: ['id'],
37
+ values: proc { |records, _ctx| records.map { nil } }
38
+ )
39
+ end
40
+
41
+ def run(datasource_customizer, _collection_customizer = nil, _options = {})
42
+ Plugins::Helpers.require_datasource!(datasource_customizer, self.class)
43
+
44
+ cfg = self.class.config
45
+ plugin = self
46
+ datasource_customizer.customize_collection(cfg[:filtered]) do |c|
47
+ c.add_field(cfg[:fk], TwoStepLinkPlugin.virtual_fk)
48
+ plugin.install_source_filter(c)
49
+ end
50
+
51
+ datasource_customizer.customize_collection(cfg[:owner]) do |c|
52
+ c.add_one_to_many_relation(cfg[:name], cfg[:filtered],
53
+ origin_key: cfg[:fk], origin_key_target: 'id')
54
+ end
55
+ end
56
+
57
+ # Installs the operator filter that rewrites the virtual FK predicate.
58
+ def install_source_filter(_collection)
59
+ raise NotImplementedError, "#{self.class} must implement #install_source_filter"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,39 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module Relations
4
+ # Two-step pre-resolution for `payment_order_id` / `incoming_payment_id`
5
+ # / ... virtual filters on Transaction (and other resources that link to
6
+ # payments via the Reconciliation pivot, not via a native FK).
7
+ # Resolves the payment ids to the set of transaction_ids through
8
+ # `Reconciliation.payment_id` + `Reconciliation.payment_type`, then
9
+ # rewrites the predicate against the host's real id field.
10
+ module TwoStepReconciliationFilter
11
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
12
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
13
+
14
+ RECONCILIATION = 'MambuReconciliation'.freeze
15
+
16
+ def self.install(collection_customizer, fk_name:, payment_type:, target_field:)
17
+ PivotResolution::SUPPORTED_OPS.each do |operator|
18
+ collection_customizer.replace_field_operator(fk_name, operator) do |value, context|
19
+ payment_ids = PivotResolution.normalize(value, operator)
20
+ next PivotResolution.no_match(target_field) if payment_ids.empty?
21
+
22
+ tx_ids = PivotResolution.collect(
23
+ context, RECONCILIATION,
24
+ PivotResolution.and_branch(
25
+ ConditionTreeLeaf.new('payment_id', Operators::IN, payment_ids),
26
+ ConditionTreeLeaf.new('payment_type', Operators::EQUAL, payment_type)
27
+ ),
28
+ 'transaction_id'
29
+ )
30
+ next PivotResolution.no_match(target_field) if tx_ids.empty?
31
+
32
+ ConditionTreeLeaf.new(target_field, Operators::IN, tx_ids)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module SmartActions
4
+ # Approves a payment order in status `pending_approval`. The Numeral API
5
+ # rejects approval for orders in any other status, so per-id rescue keeps
6
+ # one bad id from aborting a bulk approval.
7
+ class ApprovePaymentOrder < ForestAdminDatasourceCustomizer::Plugins::Plugin
8
+ BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
9
+ ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
10
+
11
+ NAMES = { single: 'Approve Mambu payment order',
12
+ bulk: 'Approve selected Mambu payment orders' }.freeze
13
+
14
+ def run(_datasource_customizer, collection_customizer = nil, options = {})
15
+ datasource = options[:datasource]
16
+ record_id_field = options[:record_id_field]
17
+ raise ArgumentError, 'ApprovePaymentOrder plugin requires :datasource' unless datasource
18
+ raise ArgumentError, 'ApprovePaymentOrder plugin requires :record_id_field' unless record_id_field
19
+ raise ArgumentError, 'ApprovePaymentOrder plugin requires a collection' unless collection_customizer
20
+
21
+ Helpers.normalize_scopes(options[:scopes]).each do |scope_key|
22
+ collection_customizer.add_action(NAMES[scope_key], build_action(datasource, scope_key, record_id_field))
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def build_action(datasource, scope_key, record_id_field)
29
+ BaseAction.new(scope: Helpers::SCOPES[scope_key], &executor(datasource, record_id_field))
30
+ end
31
+
32
+ def executor(datasource, record_id_field)
33
+ lambda do |context, result_builder|
34
+ ids = Helpers.resolve_ids(context, record_id_field)
35
+ next result_builder.error(message: "No Mambu payment order id found in '#{record_id_field}'.") if ids.empty?
36
+
37
+ succeeded, failed = Helpers.each_with_rescue(ids, 'approve_payment_order') do |id|
38
+ datasource.client.approve_payment_order(id)
39
+ end
40
+ finalize(result_builder, succeeded, failed)
41
+ end
42
+ end
43
+
44
+ def finalize(result_builder, succeeded, failed)
45
+ if succeeded.empty?
46
+ return result_builder.error(message: Messages.all_failed(failed, noun: 'payment order',
47
+ verb: 'approve'))
48
+ end
49
+
50
+ result_builder.success(message: Messages.success(succeeded, failed, noun: 'payment order',
51
+ verb_past: 'approved'))
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,66 @@
1
+ module ForestAdminDatasourceMambuPayments
2
+ module Plugins
3
+ module SmartActions
4
+ # Cancels a payment order. The optional `reason` is only used by Numeral
5
+ # for SEPA direct debit cancelations before settlement; for other payment
6
+ # types it is accepted and ignored.
7
+ class CancelPaymentOrder < ForestAdminDatasourceCustomizer::Plugins::Plugin
8
+ BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
9
+ ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
10
+ FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType
11
+
12
+ NAMES = { single: 'Cancel Mambu payment order',
13
+ bulk: 'Cancel selected Mambu payment orders' }.freeze
14
+
15
+ def run(_datasource_customizer, collection_customizer = nil, options = {})
16
+ datasource = options[:datasource]
17
+ record_id_field = options[:record_id_field]
18
+ raise ArgumentError, 'CancelPaymentOrder plugin requires :datasource' unless datasource
19
+ raise ArgumentError, 'CancelPaymentOrder plugin requires :record_id_field' unless record_id_field
20
+ raise ArgumentError, 'CancelPaymentOrder plugin requires a collection' unless collection_customizer
21
+
22
+ Helpers.normalize_scopes(options[:scopes]).each do |scope_key|
23
+ collection_customizer.add_action(NAMES[scope_key], build_action(datasource, scope_key, record_id_field))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def build_action(datasource, scope_key, record_id_field)
30
+ BaseAction.new(scope: Helpers::SCOPES[scope_key], form: form, &executor(datasource, record_id_field))
31
+ end
32
+
33
+ def form
34
+ [{ type: FieldType::STRING, label: 'Reason',
35
+ description: 'Optional reason code (SEPA direct debit only).' }]
36
+ end
37
+
38
+ def executor(datasource, record_id_field)
39
+ lambda do |context, result_builder|
40
+ ids = Helpers.resolve_ids(context, record_id_field)
41
+ next result_builder.error(message: "No Mambu payment order id found in '#{record_id_field}'.") if ids.empty?
42
+
43
+ payload = {}
44
+ reason = context.form_values['Reason']
45
+ payload['reason'] = reason if Helpers.present?(reason)
46
+
47
+ succeeded, failed = Helpers.each_with_rescue(ids, 'cancel_payment_order') do |id|
48
+ datasource.client.cancel_payment_order(id, payload)
49
+ end
50
+ finalize(result_builder, succeeded, failed)
51
+ end
52
+ end
53
+
54
+ def finalize(result_builder, succeeded, failed)
55
+ if succeeded.empty?
56
+ return result_builder.error(message: Messages.all_failed(failed, noun: 'payment order',
57
+ verb: 'cancel'))
58
+ end
59
+
60
+ result_builder.success(message: Messages.success(succeeded, failed, noun: 'payment order',
61
+ verb_past: 'canceled'))
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end