feat(mambu_payments): add Mambu/Numeral payments datasource#317
feat(mambu_payments): add Mambu/Numeral payments datasource#317christophebrun-forest wants to merge 24 commits into
Conversation
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>
44 new issues
|
| gem 'rspec', '~> 3.0' | ||
| gem 'simplecov', '~> 0.22', require: false | ||
| gem 'webmock', '~> 3.0' | ||
| 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 = {}) |
| 'date' => a['date'], | ||
| 'bank_data' => a['bank_data'], | ||
| 'created_at' => a['created_at'] | ||
| } |
| datasource.client.cancel_payment_order(id, payload) | ||
| end | ||
| finalize(result_builder, succeeded, failed) | ||
| end |
| 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 |
| datasource.client.update_account_holder(id, payload) | ||
| end | ||
| finalize(result_builder, succeeded, failed) | ||
| end |
| end | ||
| end | ||
| end | ||
| end |
| datasource.client.update_external_account(id, payload) | ||
| end | ||
| finalize(result_builder, succeeded, failed) | ||
| end |
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 |
|
|
||
| # 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) |
| end | ||
|
|
||
| # rubocop:disable Metrics/ParameterLists | ||
| def self.link(host:, name:, local_fk:, intermediate:, import_path:, many_to_one_name: 'account_holder') |
| attr_reader :config | ||
| end | ||
|
|
||
| def self.link(host:, to:, name:, origin_key:) |
| attr_reader :config | ||
| end | ||
|
|
||
| def self.link(owner:, filtered:, name:, foreign_key:) |
| end | ||
| end | ||
| end | ||
| end |
| datasource.client.update_internal_account(id, payload) | ||
| end | ||
| finalize(result_builder, succeeded, failed) | ||
| end |
| else | ||
| raise UnsupportedOperatorError, | ||
| "Operator '#{operator}' is declared in api_filters but has no translation rule." | ||
| end |
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 (
|
|
The datasource implementation for the release is missing |
- 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')) |
| foreign_collection: 'MambuExternalAccount', | ||
| foreign_key: 'external_account_id', | ||
| foreign_key_target: 'id' | ||
| )) |
| foreign_collection: 'MambuExternalAccount', | ||
| foreign_key: 'external_account_id', | ||
| foreign_key_target: 'id' | ||
| )) |
| foreign_collection: 'MambuExternalAccount', | ||
| foreign_key: 'external_account_id', | ||
| foreign_key_target: 'id' | ||
| )) |
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)DisableSearch— turns off the search bar (Numeral has no free-text search).Architecture
BaseCollectiontemplate owns the read path: list, id-lookup, pagination, relation embedding and schema-derivedbuild_payload. Each collection declares its REST resource (client_resource), itsserialize, its server-filterable fields (collection_filters) and itsmany_to_one_embeds.ConditionTreeTranslatormaps Forest condition trees to Numeral query params and raises on anything it can't translate (loud failure rather than silently returning wrong data).Clientwraps Faraday (retry/backoff, structuredAPIErrorcarrying HTTP status + body).Aligned with the real Numeral API
The implementation was validated against the Numeral API docs / OpenAPI:
api_filterscan actually serve, so the UI never offers a filter that 500s. There is noid/idslist filter, so id lookups use the per-idGET /resource/:idendpoint and combinedid AND …predicates raise loudly.starting_after+limit≤ 100); the collection walks the cursor to cover Forest's[offset, offset+limit)window.{ "records": [...] }, nototal), so collections are declared non-countable rather than returning a wrong number.DisableSearchplugin (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
forest_admin_datasource_mambu_paymentsgem 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.BaseCollectionwith a unified listing pipeline: server-side filter translation, id-based short-circuit lookups, relation embedding, and explicit rejection of aggregation.ConditionTreeTranslatorto convert Forest filter trees into Numeral query parameters, rejecting unsupported operators, OR aggregations, and empty IN sets.Changes since #317 opened
forest_admin_datasource_mambu_paymentspackage into CI/CD pipeline [cc99174]forest_admin_datasource_mambu_paymentspackage [cc99174]version.rbfrom RuboCop string literal style enforcement [cc99174]ForestAdminDatasourceMambuPayments[cc99174]Macroscope summarized 637d001.