Skip to content

feat(mambu_payments): add Mambu/Numeral payments datasource#317

Open
christophebrun-forest wants to merge 24 commits into
mainfrom
feat/mambu-payments-datasource
Open

feat(mambu_payments): add Mambu/Numeral payments datasource#317
christophebrun-forest wants to merge 24 commits into
mainfrom
feat/mambu-payments-datasource

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Jun 12, 2026

Copy link
Copy Markdown
Member

What

Adds forest_admin_datasource_mambu_payments, a native Forest Admin datasource for the Mambu Payments (Numeral) API: 17 collections, native ManyToOne/OneToMany relations, and a set of installable plugins (relations, smart actions, search toggle).

Collections

Connected accounts, payment orders, transactions, balances, account holders, external/internal accounts, incoming payments, direct debit mandates, expected payments, events (polymorphic relation), files, returns, claims, reconciliations, payment captures, payee verification requests.

Plugins (installed at the datasource level via @agent.use)

  • Relations — reciprocal OneToMany links and transitive "two-step" relation filters (holder / connected-account / reconciliation pivots).
  • Smart actions — create/update account holders & accounts, create/approve/cancel payment orders, trigger payee verification.
  • DisableSearch — turns off the search bar (Numeral has no free-text search).

Architecture

  • A shared BaseCollection template owns the read path: list, id-lookup, pagination, relation embedding and schema-derived build_payload. Each collection declares its REST resource (client_resource), its serialize, its server-filterable fields (collection_filters) and its many_to_one_embeds.
  • ConditionTreeTranslator maps Forest condition trees to Numeral query params and raises on anything it can't translate (loud failure rather than silently returning wrong data).
  • Client wraps Faraday (retry/backoff, structured APIError carrying HTTP status + body).

Aligned with the real Numeral API

The implementation was validated against the Numeral API docs / OpenAPI:

  • Filtering — each column only advertises the operators api_filters can actually serve, so the UI never offers a filter that 500s. There is no id/ids list filter, so id lookups use the per-id GET /resource/:id endpoint and combined id AND … predicates raise loudly.
  • Pagination — cursor-based (starting_after + limit ≤ 100); the collection walks the cursor to cover Forest's [offset, offset+limit) window.
  • Count — Numeral exposes no count (the list envelope is { "records": [...] }, no total), so collections are declared non-countable rather than returning a wrong number.
  • Search — disabled via the DisableSearch plugin (Numeral can filter but not free-text search).

Tests

482 examples, 0 failures; RuboCop clean. Specs cover the client (params, wrappers, error shapes), the translator, pagination, every collection (schema, list, relations, writes), and the plugins.

🤖 Generated with Claude Code

Note

Add Mambu/Numeral payments datasource with collections, relations, and smart actions

  • Introduces a new forest_admin_datasource_mambu_payments gem that exposes Numeral payment resources (connected accounts, internal/external accounts, payment orders, incoming payments, transactions, reconciliations, returns, balances, claims, events, files, mandates, and more) as Forest Admin collections via a REST client.
  • Implements a shared BaseCollection with a unified listing pipeline: server-side filter translation, id-based short-circuit lookups, relation embedding, and explicit rejection of aggregation.
  • Adds a ConditionTreeTranslator to convert Forest filter trees into Numeral query parameters, rejecting unsupported operators, OR aggregations, and empty IN sets.
  • Provides a suite of relation plugins (one-to-many, two-step via reconciliation, account-holder transitive links) that wire virtual foreign keys and rewrite EQUAL/IN filter operators through intermediate collections.
  • Registers smart action plugins for payment order approve/cancel, account holder and external/internal account create/update, payment order creation, and payee verification, each with per-id error isolation and partial-success reporting.
  • Ships comprehensive RSpec coverage for all collections, relation plugins, smart actions, the HTTP client, and the condition tree translator.

Changes since #317 opened

  • Integrated forest_admin_datasource_mambu_payments package into CI/CD pipeline [cc99174]
  • Added development and test dependencies for forest_admin_datasource_mambu_payments package [cc99174]
  • Excluded version.rb from RuboCop string literal style enforcement [cc99174]
  • Changed VERSION constant string literal quote style in ForestAdminDatasourceMambuPayments [cc99174]

Macroscope summarized 637d001.

christophebrun-forest and others added 22 commits May 25, 2026 11:11
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) <noreply@anthropic.com>
…d 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…unts 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…f 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Jun 12, 2026

Copy link
Copy Markdown

44 new issues

Tool Category Rule Count
qlty Duplication Found 15 lines of similar code in 2 locations (mass = 82) 16
qlty Structure Function with high complexity (count = 5): reconcile_filter_operators! 14
qlty Structure Function with many parameters (count = 4): post_action_resource 13
qlty Duplication Found 17 lines of identical code in 2 locations (mass = 76) 1

gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
gem 'webmock', '~> 3.0'
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of identical code in 2 locations (mass = 76) [qlty:identical-code]


# 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 = {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): post_action_resource [qlty:function-parameters]

'date' => a['date'],
'bank_data' => a['bank_data'],
'created_at' => a['created_at']
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 15 lines of similar code in 2 locations (mass = 82) [qlty:similar-code]

datasource.client.cancel_payment_order(id, payload)
end
finalize(result_builder, succeeded, failed)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): executor [qlty:function-complexity]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): executor [qlty:function-complexity]

datasource.client.update_account_holder(id, payload)
end
finalize(result_builder, succeeded, failed)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 6): executor [qlty:function-complexity]

end
end
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 76 lines of similar code in 2 locations (mass = 396) [qlty:similar-code]

datasource.client.update_external_account(id, payload)
end
finalize(result_builder, succeeded, failed)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 6): executor [qlty:function-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) <noreply@anthropic.com>
cursor = record_id(batch.last)
break if cursor.to_s.empty?
end
collected

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): fetch_window [qlty:function-complexity]


# Bulk-fetches the related records for a ManyToOne relation in a single
# batched pass and writes the serialized record back onto each row.
def embed_many_to_one(rows, sources, projection, embed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): embed_many_to_one [qlty:function-parameters]

end

# rubocop:disable Metrics/ParameterLists
def self.link(host:, name:, local_fk:, intermediate:, import_path:, many_to_one_name: 'account_holder')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 6): link [qlty:function-parameters]

attr_reader :config
end

def self.link(host:, to:, name:, origin_key:)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): link [qlty:function-parameters]

attr_reader :config
end

def self.link(owner:, filtered:, name:, foreign_key:)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): link [qlty:function-parameters]

end
end
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 76 lines of similar code in 2 locations (mass = 396) [qlty:similar-code]

datasource.client.update_internal_account(id, payload)
end
finalize(result_builder, succeeded, failed)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 6): executor [qlty:function-complexity]

else
raise UnsupportedOperatorError,
"Operator '#{operator}' is declared in api_filters but has no translation rule."
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): translate_value [qlty:function-complexity]

@christophebrun-forest

Copy link
Copy Markdown
Member Author

Suite aux retours automatiques (qlty.sh / Macroscope)

Les deux bots n'ont relevé aucun problème de correctness ni de sécurité — uniquement de la maintenabilité (duplication + complexité). Voici ce qui a été traité et ce qui est volontairement différé.

✅ Traité — déduplication des plugins de relation (637d001f)

Les 15 plugins link_* reposent désormais sur 3 classes de base déclaratives :

  • OneToManyLinkPlugin — 8 liens OneToMany simples (FK native)
  • TwoStepLinkPlugin — 4 liens à FK virtuelle résolus via un pivot
  • HolderLinkPlugin — 3 liens AccountHolder transitifs (import field)

Chaque plugin concret devient une courte sous-classe déclarative → supprime les flags similar/identical code.

✅ Traité — réduction de complexité

  • BaseCollection#paginate (complexité 10) découpé en effective_limit / fetch_window / cursor_params.
  • embed_many_to_one : arguments regroupés dans un Embed struct (supprime le rubocop:disable Metrics/ParameterLists).
  • Client : extraction de fallback_records et join_errors.

⏭️ Différé volontairement

  • Duplication serialize / define_schema (17 collections) : chaque collection déclare des champs différents ; la dédup imposerait un schéma data-driven (gros refacto, lisibilité discutable). À considérer séparément si l'équipe le souhaite.

⚪️ Faux positifs (non actionnables)

  • Gemfile (boilerplate identique à chaque package du monorepo).
  • aggregate(caller, filter, aggregation, limit) : signature imposée par le framework.
  • configuration#initialize (5) / messages.success (4) : nombre de params acceptable.

Note sur l'alignement API Numeral

Au-delà des bots, l'implémentation a été vérifiée contre l'OpenAPI Numeral et ajustée : pas de filtre id (lookup par find unitaire), pagination par curseur (starting_after), count désactivé (aucun total ni endpoint de comptage), recherche désactivée (plugin DisableSearch).

Tests : 482 exemples, 0 échec ; RuboCop clean.

PMerlet
PMerlet previously approved these changes Jun 12, 2026

@PMerlet PMerlet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@matthv

matthv commented Jun 12, 2026

Copy link
Copy Markdown
Member

The datasource implementation for the release is missing
.releaserc.js
github workflow

- 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) <noreply@anthropic.com>
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'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 8 lines of similar code in 4 locations (mass = 66) [qlty:similar-code]

foreign_collection: 'MambuExternalAccount',
foreign_key: 'external_account_id',
foreign_key_target: 'id'
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of similar code in 4 locations (mass = 66) [qlty:similar-code]

foreign_collection: 'MambuExternalAccount',
foreign_key: 'external_account_id',
foreign_key_target: 'id'
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of similar code in 4 locations (mass = 66) [qlty:similar-code]

foreign_collection: 'MambuExternalAccount',
foreign_key: 'external_account_id',
foreign_key_target: 'id'
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of similar code in 4 locations (mass = 66) [qlty:similar-code]

@matthv matthv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for me for the release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants