diff --git a/README.md b/README.md index df25687..e1be30b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Do yourself a favor: go grab some ice cubes by installing this refreshing librar - [Transfers](#transfers) - [Transfer Batches](#transfer-batches) - [Recipients](#recipients) + - [Crossborder](#crossborder) - [Simulate](#simulate) - [Account Verifications](#account-verifications) - [Entity onboardings API Examples](#entity-onboardings-api-examples) @@ -273,6 +274,9 @@ eligibility = client.v2.payment_intents.check_eligibility( ) checkout_session = client.v2.checkout_sessions.create(currency: 'CLP', flow: 'payment', amount: 5000) client.v2.checkout_sessions.expire('checkout_session_id') +# Charge the card submitted to a `ui_mode: 'fields'` session. Call it again while +# the session is still `in_progress` (202 Accepted) to get the result. +checkout_session = client.v2.checkout_sessions.confirm('checkout_session_id') setup_intent = client.v2.setup_intents.get('setup_intent_id') # Products, subscriptions and invoices @@ -571,6 +575,38 @@ recipient = client.v2.recipients.update('recipient_id', alias: 'Lord Snow') client.v2.recipients.delete('recipient_id') ``` +#### Crossborder + +Crossborder beneficiaries, quotes and transfers are under the `v2.crossborder` namespace. +Create a quote to lock the exchange rate, and accept it with a transfer to a beneficiary: + +```ruby +require 'fintoc' + +client = Fintoc::Client.new('api_key') + +beneficiary = client.v2.crossborder.beneficiaries.create( + holder_type: 'business', + business_name: 'Acme LLC', + account_identifier: '123456789', + routing_number: '021000021', + bank_name: 'JPMorgan Chase Bank, N.A.', + address: { street_line1: '100 Main St', city: 'New York', state: 'NY', postal_code: '10001' } +) +beneficiaries = client.v2.crossborder.beneficiaries.list(limit: 10) +client.v2.crossborder.beneficiaries.delete(beneficiary.id) # archives the beneficiary + +quote = client.v2.crossborder.quotes.create(direction: 'mx_to_usd', source_amount: 200001) +quote = client.v2.crossborder.quotes.get(quote.id) + +transfer = client.v2.crossborder.transfers.create( + quote_id: quote.id, beneficiary_id: beneficiary.id, reference: 'Factura 123' +) +transfer.funding_instructions[:clabe] +transfers = client.v2.crossborder.transfers.list(status: %w[pending_funds failed], direction: 'mx_to_usd') +transfer = client.v2.crossborder.transfers.get('crossborder_transfer_id') +``` + #### Simulate ```ruby diff --git a/lib/fintoc/errors.rb b/lib/fintoc/errors.rb index 13f6b9d..95f9466 100644 --- a/lib/fintoc/errors.rb +++ b/lib/fintoc/errors.rb @@ -88,6 +88,8 @@ class InvalidRefundToGetVoucherError < FintocError; end class InvalidRutError < FintocError; end class InvalidShareholderStructureError < FintocError; end class InvalidWebhookEventTypeError < FintocError; end + class QuoteExpiredError < FintocError; end + class SettlementNotConfiguredError < FintocError; end class MinimumAmountError < FintocError; end class MutuallyExclusiveParamsError < FintocError; end class NotSupportedRefundableResourceTypeError < FintocError; end @@ -115,6 +117,7 @@ class InvalidJwsTsError < FintocError; end # 402 Payment Required Errors class PaymentRequiredError < FintocError; end + class CardDeclinedError < FintocError; end # 403 Forbidden Errors class InvalidAccountError < FintocError; end @@ -176,9 +179,17 @@ class PaymentMethodNonCancelableError < FintocError; end class PaymentNonCancelableError < FintocError; end class PaymentNonExpirableError < FintocError; end class SubscriptionNotEditableError < FintocError; end + class BeneficiaryAlreadyExistsError < FintocError; end + class CardConfirmationExpiredError < FintocError; end + class ChargeInProgressError < FintocError; end + class CheckoutSessionNotInProgressError < FintocError; end + class NoCardSubmittedError < FintocError; end + class QuoteAlreadyAcceptedError < FintocError; end # 422 Unprocessable Entity Errors class AccountBlockedForDirectDebitError < FintocError; end + class AccountCertificateUnavailableError < FintocError; end + class ProviderRejectedError < FintocError; end class FounderEmailChangeLimitReachedError < FintocError; end class InvalidHolderNameError < FintocError; end class InvalidOtpCodeError < FintocError; end @@ -245,6 +256,10 @@ class CoreResponseError < FintocError; end # 501 Not Implemented Errors class OperationNotImplementedError < FintocError; end + # 503 Service Unavailable Errors + class ProviderOutcomeUnknownError < FintocError; end + class ProviderUnavailableError < FintocError; end + # Webhook Errors class WebhookSignatureError < FintocError; end diff --git a/lib/fintoc/v2/client/client.rb b/lib/fintoc/v2/client/client.rb index 9d050e6..ab920fe 100644 --- a/lib/fintoc/v2/client/client.rb +++ b/lib/fintoc/v2/client/client.rb @@ -16,6 +16,7 @@ require 'fintoc/v2/managers/events_manager' require 'fintoc/v2/managers/invoices_manager' require 'fintoc/v2/managers/subscriptions_manager' +require 'fintoc/v2/managers/crossborder_manager' module Fintoc module V2 @@ -87,6 +88,10 @@ def invoices def subscriptions @subscriptions ||= Managers::SubscriptionsManager.new(self) end + + def crossborder + @crossborder ||= Managers::CrossborderManager.new(self) + end end end end diff --git a/lib/fintoc/v2/managers/checkout_sessions_manager.rb b/lib/fintoc/v2/managers/checkout_sessions_manager.rb index 7e18477..523dd85 100644 --- a/lib/fintoc/v2/managers/checkout_sessions_manager.rb +++ b/lib/fintoc/v2/managers/checkout_sessions_manager.rb @@ -31,6 +31,12 @@ def expire(checkout_session_id, idempotency_key: nil) build_checkout_session(data) end + def confirm(checkout_session_id, idempotency_key: nil) + data = @client.post(version: :v2, idempotency_key:) + .call("checkout_sessions/#{checkout_session_id}/confirm") + build_checkout_session(data) + end + private def build_checkout_session(data) diff --git a/lib/fintoc/v2/managers/crossborder_beneficiaries_manager.rb b/lib/fintoc/v2/managers/crossborder_beneficiaries_manager.rb new file mode 100644 index 0000000..70fa105 --- /dev/null +++ b/lib/fintoc/v2/managers/crossborder_beneficiaries_manager.rb @@ -0,0 +1,46 @@ +require 'fintoc/v2/resources/crossborder_beneficiary' + +module Fintoc + module V2 + module Managers + class CrossborderBeneficiariesManager + def initialize(client) + @client = client + end + + def create( + holder_type:, account_identifier:, routing_number:, bank_name:, address:, + idempotency_key: nil, **params + ) + data = @client.post(version: :v2, idempotency_key:).call( + 'crossborder/beneficiaries', + holder_type:, account_identifier:, routing_number:, bank_name:, address:, **params + ) + build_beneficiary(data) + end + + def list(**params) + @client.get(version: :v2).call('crossborder/beneficiaries', **params).map do |data| + build_beneficiary(data) + end + end + + def get(beneficiary_id) + data = @client.get(version: :v2).call("crossborder/beneficiaries/#{beneficiary_id}") + build_beneficiary(data) + end + + def delete(beneficiary_id) + data = @client.delete(version: :v2).call("crossborder/beneficiaries/#{beneficiary_id}") + build_beneficiary(data) + end + + private + + def build_beneficiary(data) + Fintoc::V2::CrossborderBeneficiary.new(**data) + end + end + end + end +end diff --git a/lib/fintoc/v2/managers/crossborder_manager.rb b/lib/fintoc/v2/managers/crossborder_manager.rb new file mode 100644 index 0000000..b2d8aac --- /dev/null +++ b/lib/fintoc/v2/managers/crossborder_manager.rb @@ -0,0 +1,27 @@ +require 'fintoc/v2/managers/crossborder_beneficiaries_manager' +require 'fintoc/v2/managers/crossborder_quotes_manager' +require 'fintoc/v2/managers/crossborder_transfers_manager' + +module Fintoc + module V2 + module Managers + class CrossborderManager + def initialize(client) + @client = client + end + + def beneficiaries + @beneficiaries ||= CrossborderBeneficiariesManager.new(@client) + end + + def quotes + @quotes ||= CrossborderQuotesManager.new(@client) + end + + def transfers + @transfers ||= CrossborderTransfersManager.new(@client) + end + end + end + end +end diff --git a/lib/fintoc/v2/managers/crossborder_quotes_manager.rb b/lib/fintoc/v2/managers/crossborder_quotes_manager.rb new file mode 100644 index 0000000..bcffbe8 --- /dev/null +++ b/lib/fintoc/v2/managers/crossborder_quotes_manager.rb @@ -0,0 +1,30 @@ +require 'fintoc/v2/resources/crossborder_quote' + +module Fintoc + module V2 + module Managers + class CrossborderQuotesManager + def initialize(client) + @client = client + end + + def create(direction:, source_amount:, idempotency_key: nil) + data = @client.post(version: :v2, idempotency_key:) + .call('crossborder/quotes', direction:, source_amount:) + build_quote(data) + end + + def get(quote_id) + data = @client.get(version: :v2).call("crossborder/quotes/#{quote_id}") + build_quote(data) + end + + private + + def build_quote(data) + Fintoc::V2::CrossborderQuote.new(**data) + end + end + end + end +end diff --git a/lib/fintoc/v2/managers/crossborder_transfers_manager.rb b/lib/fintoc/v2/managers/crossborder_transfers_manager.rb new file mode 100644 index 0000000..1f4a25c --- /dev/null +++ b/lib/fintoc/v2/managers/crossborder_transfers_manager.rb @@ -0,0 +1,38 @@ +require 'fintoc/v2/resources/crossborder_transfer' + +module Fintoc + module V2 + module Managers + class CrossborderTransfersManager + def initialize(client) + @client = client + end + + def create(quote_id:, idempotency_key: nil, **params) + data = @client.post(version: :v2, idempotency_key:) + .call('crossborder/transfers', quote_id:, **params) + build_transfer(data) + end + + # The API expects the status filter as `status[]`; `status:` accepts a string or an array. + def list(status: nil, **params) + params[:'status[]'] = Array(status) unless status.nil? + @client.get(version: :v2).call('crossborder/transfers', **params).map do |data| + build_transfer(data) + end + end + + def get(transfer_id) + data = @client.get(version: :v2).call("crossborder/transfers/#{transfer_id}") + build_transfer(data) + end + + private + + def build_transfer(data) + Fintoc::V2::CrossborderTransfer.new(**data) + end + end + end + end +end diff --git a/lib/fintoc/v2/resources/crossborder_beneficiary.rb b/lib/fintoc/v2/resources/crossborder_beneficiary.rb new file mode 100644 index 0000000..c04e53b --- /dev/null +++ b/lib/fintoc/v2/resources/crossborder_beneficiary.rb @@ -0,0 +1,48 @@ +module Fintoc + module V2 + class CrossborderBeneficiary + attr_reader :id, :object, :mode, :holder_type, :business_name, :first_name, :last_name, + :bank_name, :routing_number, :account_last4, :address, :archived_at, + :created_at + + def initialize( + id:, + object:, + mode: nil, + holder_type: nil, + business_name: nil, + first_name: nil, + last_name: nil, + bank_name: nil, + routing_number: nil, + account_last4: nil, + address: nil, + archived_at: nil, + created_at: nil, + ** + ) + @id = id + @object = object + @mode = mode + @holder_type = holder_type + @business_name = business_name + @first_name = first_name + @last_name = last_name + @bank_name = bank_name + @routing_number = routing_number + @account_last4 = account_last4 + @address = address + @archived_at = archived_at + @created_at = created_at + end + + def archived? + !@archived_at.nil? + end + + def to_s + "Crossborder beneficiary (#{@id}) - #{@bank_name} ****#{@account_last4}" + end + end + end +end diff --git a/lib/fintoc/v2/resources/crossborder_quote.rb b/lib/fintoc/v2/resources/crossborder_quote.rb new file mode 100644 index 0000000..c02ee04 --- /dev/null +++ b/lib/fintoc/v2/resources/crossborder_quote.rb @@ -0,0 +1,46 @@ +module Fintoc + module V2 + class CrossborderQuote + attr_reader :id, :object, :mode, :status, :direction, :source_amount, :source_currency, + :target_amount, :target_currency, :exchange_rate, :fees, :transfer_id, + :expires_at, :created_at + + def initialize( + id:, + object:, + mode: nil, + status: nil, + direction: nil, + source_amount: nil, + source_currency: nil, + target_amount: nil, + target_currency: nil, + exchange_rate: nil, + fees: nil, + transfer_id: nil, + expires_at: nil, + created_at: nil, + ** + ) + @id = id + @object = object + @mode = mode + @status = status + @direction = direction + @source_amount = source_amount + @source_currency = source_currency + @target_amount = target_amount + @target_currency = target_currency + @exchange_rate = exchange_rate + @fees = fees + @transfer_id = transfer_id + @expires_at = expires_at + @created_at = created_at + end + + def to_s + "Crossborder quote (#{@id}) - #{@direction} #{@status}" + end + end + end +end diff --git a/lib/fintoc/v2/resources/crossborder_transfer.rb b/lib/fintoc/v2/resources/crossborder_transfer.rb new file mode 100644 index 0000000..8c5fb30 --- /dev/null +++ b/lib/fintoc/v2/resources/crossborder_transfer.rb @@ -0,0 +1,63 @@ +require 'fintoc/v2/resources/crossborder_beneficiary' + +module Fintoc + module V2 + class CrossborderTransfer + attr_reader :id, :object, :mode, :status, :direction, :quote_id, :source_amount, + :source_currency, :target_amount, :target_currency, :exchange_rate, :fees, + :beneficiary, :reference, :funding_instructions, :credit, :funds_received_at, + :succeeded_at, :canceled_at, :failed_at, :created_at + + def initialize( # rubocop:disable Metrics/MethodLength + id:, + object:, + mode: nil, + status: nil, + direction: nil, + quote_id: nil, + source_amount: nil, + source_currency: nil, + target_amount: nil, + target_currency: nil, + exchange_rate: nil, + fees: nil, + beneficiary: nil, + reference: nil, + funding_instructions: nil, + credit: nil, + funds_received_at: nil, + succeeded_at: nil, + canceled_at: nil, + failed_at: nil, + created_at: nil, + ** + ) + @id = id + @object = object + @mode = mode + @status = status + @direction = direction + @quote_id = quote_id + @source_amount = source_amount + @source_currency = source_currency + @target_amount = target_amount + @target_currency = target_currency + @exchange_rate = exchange_rate + @fees = fees + @beneficiary = beneficiary && CrossborderBeneficiary.new(**beneficiary) + @reference = reference + @funding_instructions = funding_instructions + @credit = credit + @funds_received_at = funds_received_at + @succeeded_at = succeeded_at + @canceled_at = canceled_at + @failed_at = failed_at + @created_at = created_at + end + + def to_s + "Crossborder transfer (#{@id}) - #{@direction} #{@status}" + end + end + end +end diff --git a/spec/fixtures/v2/crossborder_beneficiary.json b/spec/fixtures/v2/crossborder_beneficiary.json new file mode 100644 index 0000000..b21546c --- /dev/null +++ b/spec/fixtures/v2/crossborder_beneficiary.json @@ -0,0 +1,22 @@ +{ + "id": "xbb_2u6XjsX2BD3F8MzKQJf4Tr7Pcxw", + "object": "crossborder_beneficiary", + "mode": "live", + "holder_type": "business", + "business_name": "Acme LLC", + "first_name": null, + "last_name": null, + "bank_name": "JPMorgan Chase Bank, N.A.", + "routing_number": "021000021", + "account_last4": "6789", + "address": { + "street_line1": "100 Main St", + "street_line2": null, + "city": "New York", + "state": "NY", + "postal_code": "10001", + "country": "US" + }, + "archived_at": null, + "created_at": "2026-11-06T14:00:00Z" +} diff --git a/spec/fixtures/v2/crossborder_quote.json b/spec/fixtures/v2/crossborder_quote.json new file mode 100644 index 0000000..a1846a8 --- /dev/null +++ b/spec/fixtures/v2/crossborder_quote.json @@ -0,0 +1,22 @@ +{ + "id": "xbq_2u6XjsX2BD3F8MzKQJf4Tr7Pcxw", + "object": "crossborder_quote", + "created_at": "2026-11-06T15:00:00Z", + "direction": "mx_to_usd", + "exchange_rate": "18.4958", + "expires_at": "2026-11-06T15:00:10Z", + "fees": [ + { + "name": "fixed_fee", + "amount": 27069, + "currency": "MXN" + } + ], + "mode": "live", + "source_amount": 200001, + "source_currency": "MXN", + "status": "open", + "target_amount": 9313, + "target_currency": "USD", + "transfer_id": null +} diff --git a/spec/fixtures/v2/crossborder_transfer.json b/spec/fixtures/v2/crossborder_transfer.json new file mode 100644 index 0000000..33b24c5 --- /dev/null +++ b/spec/fixtures/v2/crossborder_transfer.json @@ -0,0 +1,56 @@ +{ + "id": "xbt_2u6XjsX2BD3F8MzKQJf4Tr7Pcxw", + "object": "crossborder_transfer", + "mode": "live", + "status": "pending_funds", + "direction": "mx_to_usd", + "quote_id": "xbq_2u6XjsX2BD3F8MzKQJf4Tr7Pcxw", + "source_amount": 1000000, + "source_currency": "MXN", + "target_amount": 52345, + "target_currency": "USD", + "exchange_rate": "19.1034", + "fees": [ + { + "name": "fixed_fee", + "amount": 27069, + "currency": "MXN" + } + ], + "beneficiary": { + "id": "xbb_2u6XjsX2BD3F8MzKQJf4Tr7Pcxw", + "object": "crossborder_beneficiary", + "mode": "live", + "holder_type": "business", + "business_name": "Acme LLC", + "first_name": null, + "last_name": null, + "bank_name": "JPMorgan Chase Bank, N.A.", + "routing_number": "021000021", + "account_last4": "6789", + "address": { + "street_line1": "100 Main St", + "street_line2": null, + "city": "New York", + "state": "NY", + "postal_code": "10001", + "country": "US" + }, + "archived_at": null, + "created_at": "2026-11-06T14:00:00Z" + }, + "reference": "Factura 123", + "funding_instructions": { + "type": "spei", + "clabe": "000000000000000000", + "concept": "FINTOC12345", + "amount": 1000000, + "currency": "MXN" + }, + "credit": null, + "funds_received_at": null, + "succeeded_at": null, + "canceled_at": null, + "failed_at": null, + "created_at": "2026-11-06T15:00:04Z" +} diff --git a/spec/lib/fintoc/base_client_spec.rb b/spec/lib/fintoc/base_client_spec.rb index 0aa74ac..20f57c3 100644 --- a/spec/lib/fintoc/base_client_spec.rb +++ b/spec/lib/fintoc/base_client_spec.rb @@ -431,7 +431,20 @@ def expect_error(klass, status:, code:) 'organization_already_exists' => 'OrganizationAlreadyExistsError', 'invalid_products' => 'InvalidProductsError', 'invalid_tax_id' => 'InvalidTaxIdError', - 'limit_exceeded' => 'LimitExceededError' + 'limit_exceeded' => 'LimitExceededError', + 'account_certificate_unavailable' => 'AccountCertificateUnavailableError', + 'beneficiary_already_exists' => 'BeneficiaryAlreadyExistsError', + 'quote_expired' => 'QuoteExpiredError', + 'quote_already_accepted' => 'QuoteAlreadyAcceptedError', + 'settlement_not_configured' => 'SettlementNotConfiguredError', + 'provider_rejected' => 'ProviderRejectedError', + 'provider_unavailable' => 'ProviderUnavailableError', + 'provider_outcome_unknown' => 'ProviderOutcomeUnknownError', + 'card_declined' => 'CardDeclinedError', + 'card_confirmation_expired' => 'CardConfirmationExpiredError', + 'charge_in_progress' => 'ChargeInProgressError', + 'checkout_session_not_in_progress' => 'CheckoutSessionNotInProgressError', + 'no_card_submitted' => 'NoCardSubmittedError' }.each do |code, klass| it "resolves the #{code} error" do expect(client.send(:error_class, code)).to eq(Fintoc::Errors.const_get(klass)) diff --git a/spec/lib/fintoc/v2/managers/checkout_sessions_manager_spec.rb b/spec/lib/fintoc/v2/managers/checkout_sessions_manager_spec.rb index 2a51735..bae3760 100644 --- a/spec/lib/fintoc/v2/managers/checkout_sessions_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/checkout_sessions_manager_spec.rb @@ -72,4 +72,31 @@ .to be_a(Fintoc::V2::CheckoutSession).and have_attributes(payload) end end + + describe '#confirm' do + before do + stub_request(:post, "#{resource_url}/confirm") + .with(body: {}, headers: { 'Idempotency-Key' => 'confirm-key' }) + .to_return(status: 202, body: payload.to_json, headers: headers) + end + + it 'returns the checkout session while the charge is pending' do + expect(manager.confirm(payload[:id], idempotency_key: 'confirm-key')) + .to be_a(Fintoc::V2::CheckoutSession).and have_attributes(payload) + end + + context 'when the card is declined' do + before do + stub_request(:post, "#{resource_url}/confirm").to_return( + status: 402, headers: headers, + body: { error: { code: 'card_declined', message: 'Declined' } }.to_json + ) + end + + it 'raises CardDeclinedError' do + expect { manager.confirm(payload[:id]) } + .to raise_error(Fintoc::Errors::CardDeclinedError) + end + end + end end diff --git a/spec/lib/fintoc/v2/managers/crossborder_manager_spec.rb b/spec/lib/fintoc/v2/managers/crossborder_manager_spec.rb new file mode 100644 index 0000000..3c3cfa0 --- /dev/null +++ b/spec/lib/fintoc/v2/managers/crossborder_manager_spec.rb @@ -0,0 +1,200 @@ +require 'json' +require 'webmock/rspec' +require 'fintoc/v2/client/client' + +RSpec.describe Fintoc::V2::Managers::CrossborderManager do + let(:client) { Fintoc::V2::Client.new('sk_test_crossborder') } + let(:base_url) { 'https://api.fintoc.com/v2/crossborder' } + let(:headers) { { 'Content-Type' => 'application/json' } } + + def fixture(name) + JSON.parse(File.read("spec/fixtures/v2/#{name}.json"), symbolize_names: true) + end + + it 'is memoized on the v2 client' do + expect(client.crossborder).to equal(client.crossborder) + end + + describe 'resources #to_s' do + { + 'beneficiary' => 'JPMorgan Chase Bank, N.A. ****6789', + 'quote' => 'mx_to_usd open', + 'transfer' => 'mx_to_usd pending_funds' + }.each do |name, summary| + it "describes the crossborder #{name}" do + payload = fixture("crossborder_#{name}") + resource = Fintoc::V2.const_get("Crossborder#{name.capitalize}").new(**payload) + expect(resource.to_s).to eq("Crossborder #{name} (#{payload[:id]}) - #{summary}") + end + end + end + + describe '#beneficiaries' do + let(:manager) { client.crossborder.beneficiaries } + let(:payload) { fixture('crossborder_beneficiary') } + let(:collection_url) { "#{base_url}/beneficiaries" } + let(:resource_url) { "#{collection_url}/#{payload[:id]}" } + + describe '#create' do + let(:params) do + { holder_type: 'business', account_identifier: '123456789', + routing_number: payload[:routing_number], bank_name: payload[:bank_name], + address: payload[:address].compact, business_name: payload[:business_name] } + end + + before do + stub_request(:post, collection_url) + .with(body: params.to_json, headers: { 'Idempotency-Key' => 'beneficiary-create' }) + .to_return(status: 201, body: payload.to_json, headers: headers) + end + + it 'creates the beneficiary' do + expect(manager.create(**params, idempotency_key: 'beneficiary-create')) + .to be_a(Fintoc::V2::CrossborderBeneficiary).and have_attributes(payload) + end + end + + describe '#list' do + let(:filters) { { starting_after: 'xbb_previous', ending_before: 'xbb_next', limit: 1 } } + + before do + stub_request(:get, collection_url).with(query: filters) + .to_return(body: [payload].to_json, headers: headers) + end + + it 'passes pagination parameters' do + expect(manager.list(**filters)).to contain_exactly( + a_kind_of(Fintoc::V2::CrossborderBeneficiary).and(have_attributes(payload)) + ) + end + end + + describe '#get' do + before do + stub_request(:get, resource_url).to_return(body: payload.to_json, headers: headers) + end + + it 'returns the beneficiary' do + expect(manager.get(payload[:id])) + .to be_a(Fintoc::V2::CrossborderBeneficiary).and have_attributes(archived?: false) + end + end + + describe '#delete' do + let(:archived) { payload.merge(archived_at: '2026-11-09T12:00:00Z') } + + before do + stub_request(:delete, resource_url).to_return(body: archived.to_json, headers: headers) + end + + it 'returns the archived beneficiary' do + expect(manager.delete(payload[:id])) + .to have_attributes(**archived, archived?: true) + end + end + end + + describe '#quotes' do + let(:manager) { client.crossborder.quotes } + let(:payload) { fixture('crossborder_quote') } + + describe '#create' do + let(:params) { { direction: 'mx_to_usd', source_amount: 200001 } } + + before do + stub_request(:post, "#{base_url}/quotes") + .with(body: params.to_json, headers: { 'Idempotency-Key' => 'quote-create' }) + .to_return(status: 201, body: payload.to_json, headers: headers) + end + + it 'creates the quote' do + expect(manager.create(**params, idempotency_key: 'quote-create')) + .to be_a(Fintoc::V2::CrossborderQuote).and have_attributes(payload) + end + end + + describe '#get' do + before do + stub_request(:get, "#{base_url}/quotes/#{payload[:id]}") + .to_return(body: payload.to_json, headers: headers) + end + + it 'returns the quote' do + expect(manager.get(payload[:id])) + .to be_a(Fintoc::V2::CrossborderQuote).and have_attributes(payload) + end + end + + describe 'errors' do + before do + stub_request(:post, "#{base_url}/quotes").to_return( + status: 503, headers: headers, + body: { error: { type: 'api_error', code: 'provider_unavailable', + message: 'Provider unavailable' } }.to_json + ) + end + + it 'raises the provider unavailable error' do + expect { manager.create(direction: 'mx_to_usd', source_amount: 200001) } + .to raise_error(Fintoc::Errors::ProviderUnavailableError) { |error| + expect(error).to have_attributes(status: 503, code: 'provider_unavailable') + } + end + end + end + + describe '#transfers' do + let(:manager) { client.crossborder.transfers } + let(:payload) { fixture('crossborder_transfer') } + let(:collection_url) { "#{base_url}/transfers" } + let(:expected_attributes) { payload.except(:beneficiary) } + + describe '#create' do + let(:params) do + { quote_id: payload[:quote_id], beneficiary_id: payload[:beneficiary][:id], + reference: payload[:reference] } + end + + before do + stub_request(:post, collection_url) + .with(body: params.to_json, headers: { 'Idempotency-Key' => 'transfer-create' }) + .to_return(status: 201, body: payload.to_json, headers: headers) + end + + it 'creates the transfer and builds its beneficiary' do + expect(manager.create(**params, idempotency_key: 'transfer-create')) + .to be_a(Fintoc::V2::CrossborderTransfer) + .and have_attributes( + **expected_attributes, + beneficiary: a_kind_of(Fintoc::V2::CrossborderBeneficiary) + .and(have_attributes(payload[:beneficiary])) + ) + end + end + + describe '#list' do + before do + stub_request(:get, "#{collection_url}?direction=mx_to_usd&limit=10" \ + '&status%5B%5D=pending_funds&status%5B%5D=failed') + .to_return(body: [payload.merge(beneficiary: nil)].to_json, headers: headers) + end + + it 'sends the status filter as status[]' do + expect(manager.list(status: %w[pending_funds failed], direction: 'mx_to_usd', limit: 10)) + .to contain_exactly(have_attributes(**expected_attributes, beneficiary: nil)) + end + end + + describe '#get' do + before do + stub_request(:get, "#{collection_url}/#{payload[:id]}") + .to_return(body: payload.to_json, headers: headers) + end + + it 'returns the transfer' do + expect(manager.get(payload[:id])) + .to be_a(Fintoc::V2::CrossborderTransfer).and have_attributes(expected_attributes) + end + end + end +end