From 5709522b5373100efcd714b02f6fb576155d7f4b Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 1 Sep 2025 16:36:41 -0400 Subject: [PATCH 1/6] feature(transfers): Add transfers resources --- lib/fintoc/transfers/resources/transfer.rb | 96 +++++++++ spec/lib/fintoc/transfers/transfer_spec.rb | 215 +++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 lib/fintoc/transfers/resources/transfer.rb create mode 100644 spec/lib/fintoc/transfers/transfer_spec.rb diff --git a/lib/fintoc/transfers/resources/transfer.rb b/lib/fintoc/transfers/resources/transfer.rb new file mode 100644 index 0000000..18a4a03 --- /dev/null +++ b/lib/fintoc/transfers/resources/transfer.rb @@ -0,0 +1,96 @@ +require 'money' +require 'fintoc/utils' + +module Fintoc + module Transfers + class Transfer + include Utils + + attr_reader :id, :object, :amount, :currency, :direction, :status, :mode, + :post_date, :transaction_date, :comment, :reference_id, :receipt_url, + :tracking_key, :return_reason, :counterparty, :account_number, + :metadata, :created_at + + def initialize( # rubocop:disable Metrics/MethodLength + id:, + object:, + amount:, + currency:, + status:, + mode:, + counterparty:, + direction: nil, + post_date: nil, + transaction_date: nil, + comment: nil, + reference_id: nil, + receipt_url: nil, + tracking_key: nil, + return_reason: nil, + account_number: nil, + metadata: {}, + created_at: nil, + client: nil, + ** + ) + @id = id + @object = object + @amount = amount + @currency = currency + @direction = direction + @status = status + @mode = mode + @post_date = post_date + @transaction_date = transaction_date + @comment = comment + @reference_id = reference_id + @receipt_url = receipt_url + @tracking_key = tracking_key + @return_reason = return_reason + @counterparty = counterparty + @account_number = account_number + @metadata = metadata || {} + @created_at = created_at + @client = client + end + + def to_s + amount_str = Money.from_cents(@amount, @currency).format + direction_icon = inbound? ? '⬇️' : '⬆️' + "#{direction_icon} #{amount_str} (#{@id}) - #{@status}" + end + + def pending? + @status == 'pending' + end + + def succeeded? + @status == 'succeeded' + end + + def failed? + @status == 'failed' + end + + def returned? + @status == 'returned' + end + + def return_pending? + @status == 'return_pending' + end + + def rejected? + @status == 'rejected' + end + + def inbound? + @direction == 'inbound' + end + + def outbound? + @direction == 'outbound' + end + end + end +end diff --git a/spec/lib/fintoc/transfers/transfer_spec.rb b/spec/lib/fintoc/transfers/transfer_spec.rb new file mode 100644 index 0000000..296addc --- /dev/null +++ b/spec/lib/fintoc/transfers/transfer_spec.rb @@ -0,0 +1,215 @@ +require 'fintoc/transfers/resources/transfer' + +RSpec.describe Fintoc::Transfers::Transfer do + let(:api_key) { 'sk_test_SeCreT-aPi_KeY' } + let(:client) { Fintoc::Transfers::Client.new(api_key) } + + let(:counterparty_data) do + { + holder_id: 'LFHU290523OG0', + holder_name: 'Jon Snow', + account_number: '012969123456789120', + account_type: 'clabe', + institution: { + id: '40012', + name: 'BBVA MEXICO', + country: 'mx' + } + } + end + + let(:account_number_data) do + { + id: 'acno_Kasf91034gj1AD', + object: 'account_number', + account_id: 'acc_Jas92lf9adg94ka', + description: 'Mis payins', + number: '738969123456789120', + created_at: '2024-03-01T20:09:42.949787176Z', + mode: 'test', + metadata: { + id_cliente: '12343212' + } + } + end + + let(:data) do + { + id: 'tr_jKaHD105H', + object: 'transfer', + amount: 59013, + currency: 'MXN', + direction: 'outbound', + status: 'pending', + mode: 'test', + post_date: nil, + transaction_date: nil, + comment: 'Pago de credito 10451', + reference_id: '150195', + receipt_url: nil, + tracking_key: nil, + return_reason: nil, + counterparty: counterparty_data, + account_number: account_number_data, + metadata: {}, + created_at: '2020-04-17T00:00:00.000Z', + client: client + } + end + + let(:transfer) { described_class.new(**data) } + + describe '#new' do + it 'creates an instance of Transfer' do + expect(transfer).to be_an_instance_of(described_class) + end + + it 'sets all attributes correctly' do # rubocop:disable RSpec/ExampleLength + expect(transfer).to have_attributes( + id: 'tr_jKaHD105H', + object: 'transfer', + amount: 59013, + currency: 'MXN', + direction: 'outbound', + status: 'pending', + mode: 'test', + post_date: nil, + transaction_date: nil, + comment: 'Pago de credito 10451', + reference_id: '150195', + receipt_url: nil, + tracking_key: nil, + return_reason: nil, + counterparty: counterparty_data, + account_number: account_number_data, + metadata: {}, + created_at: '2020-04-17T00:00:00.000Z' + ) + end + end + + describe '#to_s' do + it 'returns a string representation' do + expect(transfer.to_s) + .to include('⬆️') + .and include('tr_jKaHD105H') + .and include('pending') + end + + context 'when transfer is inbound' do + before { data[:direction] = 'inbound' } + + it 'uses the inbound arrow' do + expect(transfer.to_s).to include('⬇️') + end + end + end + + describe 'status predicates' do + it 'responds to status predicate methods' do + expect(transfer) + .to respond_to(:pending?) + .and respond_to(:succeeded?) + .and respond_to(:failed?) + .and respond_to(:returned?) + .and respond_to(:return_pending?) + .and respond_to(:rejected?) + end + + it 'returns correct status for pending transfer' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).to be_pending + expect(transfer).not_to be_succeeded + expect(transfer).not_to be_failed + expect(transfer).not_to be_returned + expect(transfer).not_to be_return_pending + expect(transfer).not_to be_rejected + end + + context 'when status is succeeded' do + before { data[:status] = 'succeeded' } + + it 'returns correct status' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).not_to be_pending + expect(transfer).to be_succeeded + expect(transfer).not_to be_failed + expect(transfer).not_to be_returned + expect(transfer).not_to be_return_pending + expect(transfer).not_to be_rejected + end + end + + context 'when status is failed' do + before { data[:status] = 'failed' } + + it 'returns correct status' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).not_to be_pending + expect(transfer).not_to be_succeeded + expect(transfer).to be_failed + expect(transfer).not_to be_returned + expect(transfer).not_to be_return_pending + expect(transfer).not_to be_rejected + end + end + + context 'when status is returned' do + before { data[:status] = 'returned' } + + it 'returns correct status' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).not_to be_pending + expect(transfer).not_to be_succeeded + expect(transfer).not_to be_failed + expect(transfer).to be_returned + expect(transfer).not_to be_return_pending + expect(transfer).not_to be_rejected + end + end + + context 'when status is return_pending' do + before { data[:status] = 'return_pending' } + + it 'returns correct status' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).not_to be_pending + expect(transfer).not_to be_succeeded + expect(transfer).not_to be_failed + expect(transfer).not_to be_returned + expect(transfer).to be_return_pending + expect(transfer).not_to be_rejected + end + end + + context 'when status is rejected' do + before { data[:status] = 'rejected' } + + it 'returns correct status' do # rubocop:disable RSpec/MultipleExpectations + expect(transfer).not_to be_pending + expect(transfer).not_to be_succeeded + expect(transfer).not_to be_failed + expect(transfer).not_to be_returned + expect(transfer).not_to be_return_pending + expect(transfer).to be_rejected + end + end + end + + describe 'direction predicates' do + it 'responds to direction predicate methods' do + expect(transfer) + .to respond_to(:inbound?) + .and respond_to(:outbound?) + end + + it 'returns correct direction for outbound transfer' do + expect(transfer).to be_outbound + expect(transfer).not_to be_inbound + end + + context 'when direction is inbound' do + before { data[:direction] = 'inbound' } + + it 'returns correct direction' do + expect(transfer).to be_inbound + expect(transfer).not_to be_outbound + end + end + end +end From 753454d8ff92528a090686b509675afc85a0457f Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Tue, 2 Sep 2025 18:31:29 -0400 Subject: [PATCH 2/6] feature(jws): Add JWS private key to request args for use of transfers create and patch methods --- lib/fintoc/base_client.rb | 40 +++++--- lib/fintoc/client.rb | 4 +- lib/fintoc/jws.rb | 83 ++++++++++++++++ spec/lib/fintoc/jws_spec.rb | 120 +++++++++++++++++++++++ spec/lib/fintoc/transfers/client_spec.rb | 3 +- 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 lib/fintoc/jws.rb create mode 100644 spec/lib/fintoc/jws_spec.rb diff --git a/lib/fintoc/base_client.rb b/lib/fintoc/base_client.rb index 352fd82..9216961 100644 --- a/lib/fintoc/base_client.rb +++ b/lib/fintoc/base_client.rb @@ -4,18 +4,20 @@ require 'fintoc/errors' require 'fintoc/constants' require 'fintoc/version' +require 'fintoc/jws' module Fintoc class BaseClient include Utils - def initialize(api_key) + def initialize(api_key, jws_private_key: nil) @api_key = api_key @user_agent = "fintoc-ruby/#{Fintoc::VERSION}" @headers = { Authorization: "Bearer #{@api_key}", 'User-Agent': @user_agent } @link_headers = nil @link_header_pattern = '<(?.*)>;\s*rel="(?.*)"' @default_params = {} + @jws = jws_private_key ? Fintoc::JWS.new(jws_private_key) : nil end def get(version: :v1) @@ -26,18 +28,18 @@ def delete(version: :v1) request('delete', version: version) end - def post(version: :v1) - request('post', version: version) + def post(version: :v1, use_jws: false) + request('post', version: version, use_jws: use_jws) end - def patch(version: :v1) - request('patch', version: version) + def patch(version: :v1, use_jws: false) + request('patch', version: version, use_jws: use_jws) end - def request(method, version: :v1) + def request(method, version: :v1, use_jws: false) proc do |resource, **kwargs| parameters = params(method, **kwargs) - response = make_request(method, resource, parameters, version: version) + response = make_request(method, resource, parameters, version: version, use_jws: use_jws) content = JSON.parse(response.body, symbolize_names: true) if response.status.client_error? || response.status.server_error? @@ -78,16 +80,28 @@ def parse_headers(dict, link) dict end - def make_request(method, resource, parameters, version: :v1) + def build_url(resource, version: :v1) + base_url = version == :v2 ? Fintoc::Constants::BASE_URL_V2 : Fintoc::Constants::BASE_URL + "#{Fintoc::Constants::SCHEME}#{base_url}#{resource}" + end + + def make_request(method, resource, parameters, version: :v1, use_jws: false) # this is to handle url returned in the link headers # I'm sure there is a better and more clever way to solve this if resource.start_with? 'https' - client.send(method, resource) - else - base_url = version == :v2 ? Fintoc::Constants::BASE_URL_V2 : Fintoc::Constants::BASE_URL - url = "#{Fintoc::Constants::SCHEME}#{base_url}#{resource}" - client.send(method, url, parameters) + return client.send(method, resource) + end + + url = build_url(resource, version:) + + if use_jws && @jws && %w[post patch put].include?(method.downcase) + request_body = parameters[:json]&.to_json || '' + jws_signature = @jws.generate_signature(request_body) + + return client.headers('Fintoc-JWS-Signature' => jws_signature).send(method, url, parameters) end + + client.send(method, url, parameters) end def params(method, **kwargs) diff --git a/lib/fintoc/client.rb b/lib/fintoc/client.rb index 0e35f6d..166d31c 100644 --- a/lib/fintoc/client.rb +++ b/lib/fintoc/client.rb @@ -8,9 +8,9 @@ class Client attr_reader :movements, :transfers - def initialize(api_key) + def initialize(api_key, jws_private_key: nil) @movements = Fintoc::Movements::Client.new(api_key) - @transfers = Fintoc::Transfers::Client.new(api_key) + @transfers = Fintoc::Transfers::Client.new(api_key, jws_private_key: jws_private_key) end # Delegate common methods to maintain backward compatibility diff --git a/lib/fintoc/jws.rb b/lib/fintoc/jws.rb new file mode 100644 index 0000000..9110ccd --- /dev/null +++ b/lib/fintoc/jws.rb @@ -0,0 +1,83 @@ +require 'openssl' +require 'json' +require 'base64' +require 'securerandom' + +module Fintoc + class JWS + def initialize(private_key) + unless private_key.is_a?(OpenSSL::PKey::RSA) + raise ArgumentError, 'private_key must be an OpenSSL::PKey::RSA instance' + end + + @private_key = private_key + end + + def generate_signature(raw_body) + body_string = raw_body.is_a?(Hash) ? raw_body.to_json : raw_body.to_s + + headers = { + alg: 'RS256', + nonce: SecureRandom.hex(16), + ts: Time.now.to_i, + crit: %w[ts nonce] + } + + protected_base64 = base64url_encode(headers.to_json) + payload_base64 = base64url_encode(body_string) + signing_input = "#{protected_base64}.#{payload_base64}" + + signature = @private_key.sign(OpenSSL::Digest.new('SHA256'), signing_input) + signature_base64 = base64url_encode(signature) + + "#{protected_base64}.#{signature_base64}" + end + + def parse_signature(signature) + protected_b64, signature_b64 = signature.split('.') + + { + protected_headers: decode_protected_headers(protected_b64), + signature_bytes: decode_signature(signature_b64), + protected_b64: protected_b64, + signature_b64: signature_b64 + } + end + + def verify_signature(signature, payload) + parsed = parse_signature(signature) + + # Reconstruct the signing input + payload_json = payload.is_a?(Hash) ? payload.to_json : payload.to_s + payload_b64 = base64url_encode(payload_json) + signing_input = "#{parsed[:protected_b64]}.#{payload_b64}" + + # Verify with public key + public_key = @private_key.public_key + public_key.verify(OpenSSL::Digest.new('SHA256'), parsed[:signature_bytes], signing_input) + end + + private + + def decode_protected_headers(protected_b64) + padded = add_padding(protected_b64) + + protected_json = Base64.urlsafe_decode64(padded) + JSON.parse(protected_json, symbolize_names: true) + end + + def decode_signature(signature_b64) + padded = add_padding(signature_b64) + + Base64.urlsafe_decode64(padded) + end + + def add_padding(b64) + (b64.length % 4).zero? ? b64 : (b64 + ('=' * (4 - (b64.length % 4)))) + end + + def base64url_encode(data) + Base64.urlsafe_encode64(data).tr('=', '') + end + end +end diff --git a/spec/lib/fintoc/jws_spec.rb b/spec/lib/fintoc/jws_spec.rb new file mode 100644 index 0000000..bcebfec --- /dev/null +++ b/spec/lib/fintoc/jws_spec.rb @@ -0,0 +1,120 @@ +require 'fintoc/jws' +require 'openssl' +require 'json' +require 'base64' + +RSpec.describe Fintoc::JWS do + let(:private_key) do + OpenSSL::PKey::RSA.new(2048) + end + + let(:jws) { described_class.new(private_key) } + + describe '#initialize' do + it 'accepts an OpenSSL::PKey::RSA object' do + expect { described_class.new(private_key) }.not_to raise_error + end + + it 'raises an error for invalid input' do + expect { described_class.new('invalid_string') } + .to raise_error(ArgumentError, 'private_key must be an OpenSSL::PKey::RSA instance') + end + + it 'raises an error for numeric input' do + expect { described_class.new(123) } + .to raise_error(ArgumentError, 'private_key must be an OpenSSL::PKey::RSA instance') + end + end + + describe '#generate_signature' do + let(:payload) { { amount: 1000, currency: 'MXN' } } + + it 'generates a valid JWS signature' do + signature = jws.generate_signature(payload) + + expect(signature).to be_a(String) + expect(signature.split('.').length).to eq(2) + end + + it 'includes required headers' do + signature = jws.generate_signature(payload) + parsed = jws.parse_signature(signature) + + expect(parsed[:protected_headers]).to include( + alg: 'RS256', + nonce: be_a(String), + ts: be_a(Integer), + crit: %w[ts nonce] + ) + end + + it 'accepts string payloads' do + string_payload = '{"amount":1000,"currency":"MXN"}' + signature = jws.generate_signature(string_payload) + + expect(signature).to be_a(String) + expect(signature.split('.').length).to eq(2) + end + + it 'generates different signatures for the same payload' do + signature1 = jws.generate_signature(payload) + signature2 = jws.generate_signature(payload) + + expect(signature1).not_to eq(signature2) + end + + it 'generates verifiable signatures' do + signature = jws.generate_signature(payload) + + expect(jws.verify_signature(signature, payload)).to be true + end + end + + describe '#parse_signature' do + let(:payload) { { amount: 1000, currency: 'MXN' } } + let(:signature) { jws.generate_signature(payload) } + + it 'parses signature components correctly' do + parsed = jws.parse_signature(signature) + + expect(parsed).to include( + protected_headers: be_a(Hash), + signature_bytes: be_a(String), + protected_b64: be_a(String), + signature_b64: be_a(String) + ) + end + + it 'includes correct header values' do + parsed = jws.parse_signature(signature) + + expect(parsed[:protected_headers]).to include( + alg: 'RS256', + crit: %w[ts nonce] + ) + end + end + + describe '#verify_signature' do + let(:payload) { { amount: 1000, currency: 'MXN' } } + + it 'verifies valid signatures' do + signature = jws.generate_signature(payload) + expect(jws.verify_signature(signature, payload)).to be true + end + + it 'rejects signatures with wrong payload' do + signature = jws.generate_signature(payload) + wrong_payload = { amount: 2000, currency: 'CLP' } + + expect(jws.verify_signature(signature, wrong_payload)).to be false + end + + it 'works with string payloads' do + string_payload = '{"amount":1000,"currency":"MXN"}' + signature = jws.generate_signature(string_payload) + + expect(jws.verify_signature(signature, string_payload)).to be true + end + end +end diff --git a/spec/lib/fintoc/transfers/client_spec.rb b/spec/lib/fintoc/transfers/client_spec.rb index 792f052..83ac4b8 100644 --- a/spec/lib/fintoc/transfers/client_spec.rb +++ b/spec/lib/fintoc/transfers/client_spec.rb @@ -2,7 +2,8 @@ RSpec.describe Fintoc::Transfers::Client do let(:api_key) { 'sk_test_SeCreT-aPi_KeY' } - let(:client) { described_class.new(api_key) } + let(:jws_private_key) { nil } + let(:client) { described_class.new(api_key, jws_private_key: jws_private_key) } describe '.new' do it 'creates an instance of TransfersClient' do From 55a3755f34f91e6670ebbd4122cae0fcef0db758 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Tue, 2 Sep 2025 18:32:54 -0400 Subject: [PATCH 3/6] feature(transfers): Add transfers methods --- lib/fintoc/transfers/client/client.rb | 2 + .../transfers/client/transfers_methods.rb | 49 +++++++ spec/lib/fintoc/transfers/client_spec.rb | 2 + spec/lib/fintoc/transfers/transfer_spec.rb | 14 +- .../clients/transfers_client_examples.rb | 126 ++++++++++++++++++ .../returns_a_Transfer_instance.yml | 81 +++++++++++ .../returns_a_Transfer_instance.yml | 75 +++++++++++ .../accepts_filtering_parameters.yml | 77 +++++++++++ ...returns_an_array_of_Transfer_instances.yml | 80 +++++++++++ ...er_instance_with_return_pending_status.yml | 80 +++++++++++ 10 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 lib/fintoc/transfers/client/transfers_methods.rb create mode 100644 spec/support/shared_examples/clients/transfers_client_examples.rb create mode 100644 spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_create_transfer/returns_a_Transfer_instance.yml create mode 100644 spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_get_transfer/returns_a_Transfer_instance.yml create mode 100644 spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/accepts_filtering_parameters.yml create mode 100644 spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/returns_an_array_of_Transfer_instances.yml create mode 100644 spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_return_transfer/returns_a_Transfer_instance_with_return_pending_status.yml diff --git a/lib/fintoc/transfers/client/client.rb b/lib/fintoc/transfers/client/client.rb index 8ef79ee..6c945ba 100644 --- a/lib/fintoc/transfers/client/client.rb +++ b/lib/fintoc/transfers/client/client.rb @@ -2,6 +2,7 @@ require 'fintoc/transfers/client/entities_methods' require 'fintoc/transfers/client/accounts_methods' require 'fintoc/transfers/client/account_numbers_methods' +require 'fintoc/transfers/client/transfers_methods' module Fintoc module Transfers @@ -9,6 +10,7 @@ class Client < BaseClient include EntitiesMethods include AccountsMethods include AccountNumbersMethods + include TransfersMethods end end end diff --git a/lib/fintoc/transfers/client/transfers_methods.rb b/lib/fintoc/transfers/client/transfers_methods.rb new file mode 100644 index 0000000..7ab1e07 --- /dev/null +++ b/lib/fintoc/transfers/client/transfers_methods.rb @@ -0,0 +1,49 @@ +require 'fintoc/transfers/resources/transfer' + +module Fintoc + module Transfers + module TransfersMethods + def create_transfer(amount:, currency:, account_id:, counterparty:, **params) + data = _create_transfer(amount:, currency:, account_id:, counterparty:, **params) + build_transfer(data) + end + + def get_transfer(transfer_id) + data = _get_transfer(transfer_id) + build_transfer(data) + end + + def list_transfers(**params) + _list_transfers(**params).map { |data| build_transfer(data) } + end + + def return_transfer(transfer_id) + data = _return_transfer(transfer_id) + build_transfer(data) + end + + private + + def _create_transfer(amount:, currency:, account_id:, counterparty:, **params) + post(version: :v2, use_jws: true) + .call('transfers', amount:, currency:, account_id:, counterparty:, **params) + end + + def _get_transfer(transfer_id) + get(version: :v2).call("transfers/#{transfer_id}") + end + + def _list_transfers(**params) + get(version: :v2).call('transfers', **params) + end + + def _return_transfer(transfer_id) + post(version: :v2, use_jws: true).call('transfers/return', transfer_id:) + end + + def build_transfer(data) + Fintoc::Transfers::Transfer.new(**data, client: self) + end + end + end +end diff --git a/spec/lib/fintoc/transfers/client_spec.rb b/spec/lib/fintoc/transfers/client_spec.rb index 83ac4b8..cc6c026 100644 --- a/spec/lib/fintoc/transfers/client_spec.rb +++ b/spec/lib/fintoc/transfers/client_spec.rb @@ -24,4 +24,6 @@ it_behaves_like 'a client with accounts methods' it_behaves_like 'a client with account numbers methods' + + it_behaves_like 'a client with transfers methods' end diff --git a/spec/lib/fintoc/transfers/transfer_spec.rb b/spec/lib/fintoc/transfers/transfer_spec.rb index 296addc..ccffb6c 100644 --- a/spec/lib/fintoc/transfers/transfer_spec.rb +++ b/spec/lib/fintoc/transfers/transfer_spec.rb @@ -8,7 +8,7 @@ { holder_id: 'LFHU290523OG0', holder_name: 'Jon Snow', - account_number: '012969123456789120', + account_number: '735969000000203297', account_type: 'clabe', institution: { id: '40012', @@ -20,11 +20,11 @@ let(:account_number_data) do { - id: 'acno_Kasf91034gj1AD', + id: 'acno_326dzRGqxLee3j9TkaBBBMfs2i0', object: 'account_number', - account_id: 'acc_Jas92lf9adg94ka', + account_id: 'acc_31yYL7h9LVPg121AgFtCyJPDsgM', description: 'Mis payins', - number: '738969123456789120', + number: '735969000000203365', created_at: '2024-03-01T20:09:42.949787176Z', mode: 'test', metadata: { @@ -35,7 +35,7 @@ let(:data) do { - id: 'tr_jKaHD105H', + id: 'tr_329NGN1M4If6VvcMRALv4gjAQJx', object: 'transfer', amount: 59013, currency: 'MXN', @@ -66,7 +66,7 @@ it 'sets all attributes correctly' do # rubocop:disable RSpec/ExampleLength expect(transfer).to have_attributes( - id: 'tr_jKaHD105H', + id: 'tr_329NGN1M4If6VvcMRALv4gjAQJx', object: 'transfer', amount: 59013, currency: 'MXN', @@ -92,7 +92,7 @@ it 'returns a string representation' do expect(transfer.to_s) .to include('⬆️') - .and include('tr_jKaHD105H') + .and include('tr_329NGN1M4If6VvcMRALv4gjAQJx') .and include('pending') end diff --git a/spec/support/shared_examples/clients/transfers_client_examples.rb b/spec/support/shared_examples/clients/transfers_client_examples.rb new file mode 100644 index 0000000..fbf6a5d --- /dev/null +++ b/spec/support/shared_examples/clients/transfers_client_examples.rb @@ -0,0 +1,126 @@ +require 'openssl' + +RSpec.shared_examples 'a client with transfers methods' do + let(:jws_private_key) do + key_string = "-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDNLwwQr/uFToDH +8x1GHlW5gRsngNp8J+sg8vZc5jX+dISZh42CNM2eMSyPOMLSZL08xIA9ISoxjCJb +rpJ7MY9OKOZdrFheclz8e3z/hVcXpmgT0JARYIyKUb2sgoh1JsH3aSQILTc4KuDM +dm0+WIWl9tOqKXm23j9RcYL+WOUCcLYNj3xga39CnXdqT3dy2fMIOJ+vZxfSxPhG +EBTyV6v9jrMbRukhxllTqDc64WkVdt0MOvzFzcSNkGcvHRdrK1w+x5IhfsYGtv+9 +mz+fvmI88qGsb0x8peGVDgWfQjykxrB/8umpQKANn9bqjyay+ogRRwv05dmaB/gm +IycvGXE7AgMBAAECggEAI4gTKcyf3MzkZjvGhP75z175KdUZgMiU4ibQ3POMxBy/ +XaroqXSlatCPK9ojerWxQ5Wvs2ZL3TqsNH49pZHGhD127x/KSci6K4ri8YjQtSq+ ++Tdzy16R194h337XTJpCmqqdb8EMv/BE74NOla5UrpHYw63dAvrnsh3bFlqkhdBZ +E5OBfdLyxGy5FYdewV803a8XGfnDfT7RrsdWhPib8E3i+wix+/dv10AX/+Y6VPpG +2EPXRV63UtmO2EVXyIGT5kSAnzZBJPIB3EYTlm1A86PxQGVD4X8LAUXIj6VRVC8h +B1KXb5YZ9W1vYmKyWUZPyMQHMpUTNGEuU/EtN0aOCQKBgQD+zMd1+3BhoSwBusXb +EK2SBJwn9TfqdUghsFHNz0xjvpAFKpO55qA7XyilZwZeJsOzPQZ33ERCRk18crCd +Q6oWI15xKjPl+Dfxf4UYjokx/iQCCHu8lJ6TXcEwXniIs6CVsUq9QV+s6JBlb3C4 +qD/wwp7VrmbcMLfIUs3nb3tqHQKBgQDOJnGylmqC/l4BCZj9BhLiVg7nioY24lG1 +9DY0/nnnbuMtDQ+8VUKtt93Or8giejOVqj3BZ8/TflkwCQAt9cIvFG50aTVZBo2E +4uPJLGSBLrQqpuUNPR2O239o4RqGgDICkh9TRH9D9GsekLRCgefLRubUyuZZ4pI9 +j1ty2kMpNwKBgGtYJmgEKBJZbkrEPvrNifJMUuVan9X81wiqWaxVOx+CdvZWO6pE +CRk6O8uDHeGofyYR/ZmdiHxLVfWp89ItYYi2GeGfIAIwkpEBYjc4RYB0SwM4Q7js +++mlw+/2vN0KoAqwiIY29nHIAJ1bV6fT6iwqMfRf5yG4vJR+nhR0mQ/ZAoGAfgLJ +5RxEpyXNWF0Bg0i/KlLocWgfelUFFW/d4q7a3TjO7K7bO4fyZjXKA5k3gLup5IZX +kW1fgCvvYIlf7rgWpqiai9XzoiN7RgtaqZHVLZHa12eFA36kHrrVOsq+aBDcgO3I +8CEimetBv0E8rpqxkXQZjWEpRTBVrAOBJsd73ikCgYAwf4fnpTcEa4g6ejmbjkXw +yacTlKFuxyLZHZ5R7D0+Fj19gwm9EzvrRIEcX84ebiJ8P1bL3kycQmLV19zE5B3M +pcsQmZ28/Oa3xCPiy8CDyDuiDbbNfnR1Ot3IbgfFL7xPYySljJbMyl7vhKJIacWs +draAAQ5iJEb5BR8AmL6tAQ== +-----END PRIVATE KEY-----" + OpenSSL::PKey::RSA.new(key_string) + end + let(:transfer_id) { 'tr_329NGN1M4If6VvcMRALv4gjAQJx' } + let(:account_id) { 'acc_31yYL7h9LVPg121AgFtCyJPDsgM' } + + let(:counterparty) do + { + holder_id: 'LFHU290523OG0', + holder_name: 'Jon Snow', + account_number: '735969000000203297', + account_type: 'clabe', + institution_id: '40012' + } + end + + let(:transfer_data) do + { + amount: 50000, + currency: 'MXN', + account_id:, + counterparty:, + comment: 'Test payment', + reference_id: '123456' + } + end + + it 'responds to transfer-specific methods' do + expect(client) + .to respond_to(:create_transfer) + .and respond_to(:get_transfer) + .and respond_to(:list_transfers) + .and respond_to(:return_transfer) + end + + describe '#create_transfer' do + it 'returns a Transfer instance', :vcr do + transfer = client.create_transfer(**transfer_data) + + expect(transfer) + .to be_an_instance_of(Fintoc::Transfers::Transfer) + .and have_attributes( + amount: 50000, + currency: 'MXN', + comment: 'Test payment', + reference_id: '123456', + status: 'pending' + ) + end + end + + describe '#get_transfer' do + it 'returns a Transfer instance', :vcr do + transfer = client.get_transfer(transfer_id) + + expect(transfer) + .to be_an_instance_of(Fintoc::Transfers::Transfer) + .and have_attributes( + id: transfer_id, + object: 'transfer' + ) + end + end + + describe '#list_transfers' do + it 'returns an array of Transfer instances', :vcr do + transfers = client.list_transfers + + expect(transfers).to all(be_a(Fintoc::Transfers::Transfer)) + expect(transfers.size).to be >= 1 + end + + it 'accepts filtering parameters', :vcr do + transfers = client.list_transfers(status: 'succeeded', direction: 'outbound') + + expect(transfers).to all(be_a(Fintoc::Transfers::Transfer)) + expect(transfers).to all(have_attributes(status: 'succeeded', direction: 'outbound')) + end + end + + describe '#return_transfer' do + let(:transfer_id) { 'tr_329R3l5JksDkoevCGTOBsugCsnb' } + + it 'returns a Transfer instance with return_pending status', :vcr do + transfer = client.return_transfer(transfer_id) + + expect(transfer) + .to be_an_instance_of(Fintoc::Transfers::Transfer) + .and have_attributes( + id: transfer_id, + status: 'return_pending' + ) + end + end +end diff --git a/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_create_transfer/returns_a_Transfer_instance.yml b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_create_transfer/returns_a_Transfer_instance.yml new file mode 100644 index 0000000..0701bbd --- /dev/null +++ b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_create_transfer/returns_a_Transfer_instance.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/transfers + body: + encoding: UTF-8 + string: '{"amount":50000,"currency":"MXN","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","counterparty":{"holder_id":"LFHU290523OG0","holder_name":"Jon + Snow","account_number":"735969000000203297","account_type":"clabe","institution_id":"40012"},"comment":"Test + payment","reference_id":"123456"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/0.2.0 + Fintoc-Jws-Signature: + - eyJhbGciOiJSUzI1NiIsIm5vbmNlIjoiMWU0NzU1ZGNlYzFhZjc2NmNhNjQ2ZjI4NzJlOTc1MmYiLCJ0cyI6MTc1NjgyODczMSwiY3JpdCI6WyJ0cyIsIm5vbmNlIl19.jXtZ3_PClHDiL_wMDGXQqtcUu3Oomn-jRACxvmNmJkCK_ETBHNRA1TMW4SFQqSuEW12XX1R1gA2UlOoKDJOLIZHRX6d4muvbcR32gio9mGEDst9e1cHZkUG0QT7eF_Cb4mseAvjeCTbWvIV3f6-FQ9zF0biffejPVsZvg3KArgbyTt3O1_sst1bng4zuTyVaSB4XEHK8gy-HNyJOHKX9GXGGsE7Mma--LeOneDJQCcoLe3-oXmBtkHwLEFRsgNK6jF8-jYJkDPKR5oFA6EuT-Yr1oXiAhsPr5FDZy3H6i99cz14dPk0iUN-UdozW8jj7wLUtWSYLBKrWoPkm5iXn_w + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 201 + message: Created + headers: + Date: + - Tue, 02 Sep 2025 15:58:51 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '863' + Connection: + - close + Cf-Ray: + - 978e36138f38b861-EZE + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Etag: + - W/"3bcd22908f94497674e96ead614e8a01" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 96e11256-7b3e-42e6-8514-882728242e85 + X-Runtime: + - '0.293159' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Iv7AS7YO%2FSvC6uTUZgyWC2%2BaUfgdXl7xakqe1BSg2PGNCHtalgXnlviJpWj1wQJK3dsFWG9U6L0AuPx2E0tMdXKTtuVH8gXuCvBOmscmnic9ljwLzFlpxqN0l1IZeAL4"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=15552000; includeSubDomains; preload + Server: + - cloudflare + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=30125&min_rtt=30083&rtt_var=11366&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=2641&delivery_rate=140016&cwnd=252&unsent_bytes=0&cid=b848458890c32a83&ts=558&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_329NGN1M4If6VvcMRALv4gjAQJx","amount":50000,"currency":"MXN","direction":"outbound","status":"pending","transaction_date":"2025-09-02T15:58:51Z","post_date":null,"comment":"Test + payment","reference_id":"123456","tracking_key":"202509029073500000000000000006","receipt_url":null,"mode":"test","counterparty":{"holder_id":"LFHU290523OG0","holder_name":"Jon + Snow","account_number":"735969000000203297","account_type":"clabe","institution":{"id":"90735","name":"FINTOC","country":"mx"}},"account_number":{"id":"acno_31yYL5uOb39vzvRIszPhoXYMjt4","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203297","created_at":"2025-08-29T20:02:00Z","updated_at":"2025-08-29T20:02:00Z","mode":"test","description":null,"metadata":{},"status":"enabled","is_root":true,"object":"account_number"},"metadata":{},"return_reason":null}' + recorded_at: Tue, 02 Sep 2025 15:58:51 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_get_transfer/returns_a_Transfer_instance.yml b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_get_transfer/returns_a_Transfer_instance.yml new file mode 100644 index 0000000..e5409f2 --- /dev/null +++ b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_get_transfer/returns_a_Transfer_instance.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.fintoc.com/v2/transfers/tr_329NGN1M4If6VvcMRALv4gjAQJx + body: + encoding: ASCII-8BIT + string: '' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/0.2.0 + Connection: + - close + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 02 Sep 2025 15:59:54 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '1070' + Connection: + - close + Cf-Ray: + - 978e379f2c927842-EZE + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Etag: + - W/"37bbc649f214cfce2f8d9aba6caecf21" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 726056fc-bf78-483a-9983-f2453fba746b + X-Runtime: + - '0.059927' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=FAx0QL7Eef4ZkT1QR%2BkB0cgqRpZSc8mZ1kyh6SsnZcqbg6fJZ8bi7euv7xDBrfgrqPI1HfMqlYq9EgszMsvIeR37MiKYP%2FXsLOzy6%2FyMrq3a9QkVFFsPv2s7H5extDUw"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=15552000; includeSubDomains; preload + Server: + - cloudflare + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=32899&min_rtt=32105&rtt_var=12607&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=1823&delivery_rate=132689&cwnd=253&unsent_bytes=0&cid=a29d81c8d5b56467&ts=316&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_329NGN1M4If6VvcMRALv4gjAQJx","amount":50000,"currency":"MXN","direction":"outbound","status":"succeeded","transaction_date":"2025-09-02T15:58:51Z","post_date":"2025-09-02T00:00:00Z","comment":"Test + payment","reference_id":"123456","tracking_key":"202509029073500000000000000006","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=qOK7ARfoEwlVpkLWUmFvihhtr5%2B4Gf1LA%2FnWvzdRnC8vLzAGeXauPtgz1bSaTV%2BmUP9oThn6%2B%2Bujt23nuyI3u8OR00eq5WezdAT1eG%2BhBvo%3D","mode":"test","counterparty":{"holder_id":"LFHU290523OG0","holder_name":"Jon + Snow","account_number":"735969000000203297","account_type":"clabe","institution":{"id":"90735","name":"FINTOC","country":"mx"}},"account_number":{"id":"acno_31yYL5uOb39vzvRIszPhoXYMjt4","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203297","created_at":"2025-08-29T20:02:00Z","updated_at":"2025-08-29T20:02:00Z","mode":"test","description":null,"metadata":{},"status":"enabled","is_root":true,"object":"account_number"},"metadata":{},"return_reason":null}' + recorded_at: Tue, 02 Sep 2025 15:59:54 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/accepts_filtering_parameters.yml b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/accepts_filtering_parameters.yml new file mode 100644 index 0000000..c6ec808 --- /dev/null +++ b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/accepts_filtering_parameters.yml @@ -0,0 +1,77 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.fintoc.com/v2/transfers?direction=outbound&status=succeeded + body: + encoding: ASCII-8BIT + string: '' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/0.2.0 + Connection: + - close + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 02 Sep 2025 16:01:43 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '1072' + Connection: + - close + Cf-Ray: + - 978e3a451c00a4fc-GRU + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Link: + - '' + Etag: + - W/"8cd7042fdca7259ed4ef7a4518bdf6ee" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - d53e9642-da84-46ef-875c-6144fd090fd0 + X-Runtime: + - '0.064205' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2B1u%2FnTIHzyJohdVDT2cSiuJNrBQxE3acdGpl7gOBsai8IUWqJ%2B2ZgXyEbbEy37QiN9OMvKCM6%2BkDEF7sdBZNcaWXlGzUcgefErVT0PqggHoMOb7D1jlrzGh5iYP5wl3F"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=15552000; includeSubDomains; preload + Server: + - cloudflare + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=59799&min_rtt=59513&rtt_var=22521&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=1828&delivery_rate=71580&cwnd=45&unsent_bytes=0&cid=e3345cf16d5e8803&ts=392&x=0" + body: + encoding: UTF-8 + string: '[{"object":"transfer","id":"tr_329NGN1M4If6VvcMRALv4gjAQJx","amount":50000,"currency":"MXN","direction":"outbound","status":"succeeded","transaction_date":"2025-09-02T15:58:51Z","post_date":"2025-09-02T00:00:00Z","comment":"Test + payment","reference_id":"123456","tracking_key":"202509029073500000000000000006","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=qOK7ARfoEwlVpkLWUmFvihhtr5%2B4Gf1LA%2FnWvzdRnC8vLzAGeXauPtgz1bSaTV%2BmUP9oThn6%2B%2Bujt23nuyI3u8OR00eq5WezdAT1eG%2BhBvo%3D","mode":"test","counterparty":{"holder_id":"LFHU290523OG0","holder_name":"Jon + Snow","account_number":"735969000000203297","account_type":"clabe","institution":{"id":"90735","name":"FINTOC","country":"mx"}},"account_number":{"id":"acno_31yYL5uOb39vzvRIszPhoXYMjt4","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203297","created_at":"2025-08-29T20:02:00Z","updated_at":"2025-08-29T20:02:00Z","mode":"test","description":null,"metadata":{},"status":"enabled","is_root":true,"object":"account_number"},"metadata":{},"return_reason":null}]' + recorded_at: Tue, 02 Sep 2025 16:01:43 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/returns_an_array_of_Transfer_instances.yml b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/returns_an_array_of_Transfer_instances.yml new file mode 100644 index 0000000..99718b6 --- /dev/null +++ b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_list_transfers/returns_an_array_of_Transfer_instances.yml @@ -0,0 +1,80 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.fintoc.com/v2/transfers + body: + encoding: ASCII-8BIT + string: '' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/0.2.0 + Connection: + - close + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 02 Sep 2025 15:59:14 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '2218' + Connection: + - close + Cf-Ray: + - 978e36a3cd4e0ba0-EZE + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Link: + - '' + Etag: + - W/"c44d2930120083fb1fcfe202e1d2c7c5" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 9f2d1b9e-ab81-412c-b2c8-46a21d943e44 + X-Runtime: + - '0.123093' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=dGWez9dCHhHPY3gIOowmzhSbR5PjvTHLFLD2kGv8rpM2Aopvl00MqFrSvjwLfv0b8SN7sBFSRqHfTLsqRSAr0RyMm2v3L1WRrkg3IJHXnMcfpFPH9msZJiFM8Ch1a3pC"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=15552000; includeSubDomains; preload + Server: + - cloudflare + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=31982&min_rtt=31972&rtt_var=12010&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=1792&delivery_rate=132896&cwnd=253&unsent_bytes=0&cid=cc7a1dbdbfbf7d50&ts=384&x=0" + body: + encoding: UTF-8 + string: '[{"object":"transfer","id":"tr_329NGN1M4If6VvcMRALv4gjAQJx","amount":50000,"currency":"MXN","direction":"outbound","status":"succeeded","transaction_date":"2025-09-02T15:58:51Z","post_date":"2025-09-02T00:00:00Z","comment":"Test + payment","reference_id":"123456","tracking_key":"202509029073500000000000000006","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=qOK7ARfoEwlVpkLWUmFvihhtr5%2B4Gf1LA%2FnWvzdRnC8vLzAGeXauPtgz1bSaTV%2BmUP9oThn6%2B%2Bujt23nuyI3u8OR00eq5WezdAT1eG%2BhBvo%3D","mode":"test","counterparty":{"holder_id":"LFHU290523OG0","holder_name":"Jon + Snow","account_number":"735969000000203297","account_type":"clabe","institution":{"id":"90735","name":"FINTOC","country":"mx"}},"account_number":{"id":"acno_31yYL5uOb39vzvRIszPhoXYMjt4","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203297","created_at":"2025-08-29T20:02:00Z","updated_at":"2025-08-29T20:02:00Z","mode":"test","description":null,"metadata":{},"status":"enabled","is_root":true,"object":"account_number"},"metadata":{},"return_reason":null},{"object":"transfer","id":"tr_329N35lH813ZhFPBvHV8mXiKKMn","amount":10000000,"currency":"MXN","direction":"inbound","status":"succeeded","transaction_date":"2025-09-02T15:57:04Z","post_date":"2025-09-02T00:00:00Z","comment":null,"reference_id":"3","tracking_key":"202509029073500000000000000005","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=qOK7ARfoEwlVpkLWUmFvihhtr5%2B4Gf1LA%2FnWvzdRnC%2FtIlWZFTyXKH99y1SPS%2FioW%2FpFGGl1VboyxCzRY946tah7TfWJHw%2FM75NVz2XXmBZLqjqrQknbHmKnMMubiNsD","mode":"test","counterparty":{"holder_id":"VFHD640116IY8","holder_name":"Nakesha + Breitenberg","account_number":"680969192837645191","account_type":"clabe","institution":{"id":"90680","name":"CRISTOBAL + COLON","country":"mx"}},"account_number":{"id":"acno_326dzRGqxLee3j9TkaBBBMfs2i0","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203365","created_at":"2025-09-01T16:46:57Z","updated_at":"2025-09-01T16:54:40Z","mode":"test","description":"Updated + account number description","metadata":{"test_id":"12345"},"status":"enabled","is_root":false,"object":"account_number"},"metadata":{},"return_reason":null}]' + recorded_at: Tue, 02 Sep 2025 15:59:14 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_return_transfer/returns_a_Transfer_instance_with_return_pending_status.yml b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_return_transfer/returns_a_Transfer_instance_with_return_pending_status.yml new file mode 100644 index 0000000..cd92f83 --- /dev/null +++ b/spec/vcr/Fintoc_Transfers_Client/behaves_like_a_client_with_transfers_methods/_return_transfer/returns_a_Transfer_instance_with_return_pending_status.yml @@ -0,0 +1,80 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/transfers/return + body: + encoding: UTF-8 + string: '{"transfer_id":"tr_329R3l5JksDkoevCGTOBsugCsnb"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/0.2.0 + Fintoc-Jws-Signature: + - eyJhbGciOiJSUzI1NiIsIm5vbmNlIjoiMGQ3MGY1MzFkNjk0MmM4MjQzMDdiZjZlZTIzZDMxNjYiLCJ0cyI6MTc1Njg1MTYyMywiY3JpdCI6WyJ0cyIsIm5vbmNlIl19.knGOuq89bDkhnw5JwAZ57rI6Sm405T3aXA8KazSTl_DsavvEeN9MY34athHc_JZt3rsf6daOR0ZK-RnaAwdtGP33XzxGqW8iDYaUo7UysiVN0gIJUl_cJazJWxG1joG8t58RWMxyfiE-VQHYER9c_XWB-t0E17ou8WmCMUgWndlGLQ7vgr03mjWXdDT5AYoZ1jXrXqTsgzy7cUAaA0j84H2aH9KFDnKZ17bKdg4NDnSJ-lalzicQeNgjzVzA0BZ-h5t07stsJiWU_EJwJDiqHOaUSSRiqDr-H2cQpppUJMQyGwBCkbtWR0G8PG39f7e97_yFmOcHkeUCHz8nlthdKg + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 02 Sep 2025 22:20:24 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '1128' + Connection: + - close + Cf-Ray: + - 979064f7bbd7f21f-GRU + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Etag: + - W/"3954a8d24b91a22476a95d941ad9e2fa" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - c7cb38f4-7136-4d76-8837-0aa2b5aa0381 + X-Runtime: + - '0.363494' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=tVgzLymGEFhRDHaueKi3ZCGKvNSYWPxiAgv6Nbcgp0c152jGVD9oaeNNIyp575JdfHU1llo0RZ8eCm853iwMTMe9jb%2FHC3BGP73GbngYphHVz0KonnREFqeqyXX0qmJ8"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=15552000; includeSubDomains; preload + Server: + - cloudflare + Server-Timing: + - cfL4;desc="?proto=TCP&rtt=59502&min_rtt=58986&rtt_var=22488&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=2410&delivery_rate=72220&cwnd=178&unsent_bytes=0&cid=5523d13563682b99&ts=692&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_329R3l5JksDkoevCGTOBsugCsnb","amount":1000000,"currency":"MXN","direction":"inbound","status":"return_pending","transaction_date":"2025-09-02T16:30:04Z","post_date":"2025-09-02T00:00:00Z","comment":null,"reference_id":"6","tracking_key":"202509029073500000000000000009","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=qOK7ARfoEwlVpkLWUmFvihhtr5%2B4Gf1LA%2FnWvzdRnC8iI16fI6nvQ9dAhBV8NYBa7pR6J8gKo6rGoff3qWJ2xOPIDPJsG2njYPkZrzpG1waTgRRYwe2jS7Y4eikheYeK","mode":"test","counterparty":{"holder_id":"RBZZ190718TEA","holder_name":"Dede + Kuhlman","account_number":"110969987654321988","account_type":"clabe","institution":{"id":"40110","name":"JP + MORGAN","country":"mx"}},"account_number":{"id":"acno_326dzRGqxLee3j9TkaBBBMfs2i0","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000203365","created_at":"2025-09-01T16:46:57Z","updated_at":"2025-09-01T16:54:40Z","mode":"test","description":"Updated + account number description","metadata":{"test_id":"12345"},"status":"enabled","is_root":false,"object":"account_number"},"metadata":{},"return_reason":null}' + recorded_at: Tue, 02 Sep 2025 22:20:24 GMT +recorded_with: VCR 6.3.1 From 63d72befc50dc4c95c02750e8754b25bbdcc8ea3 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Tue, 2 Sep 2025 18:33:30 -0400 Subject: [PATCH 4/6] feature(transfers): Add refresh method to transfers --- lib/fintoc/transfers/resources/transfer.rb | 31 ++++++++++++++++++++++ spec/lib/fintoc/transfers/transfer_spec.rb | 17 ++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/fintoc/transfers/resources/transfer.rb b/lib/fintoc/transfers/resources/transfer.rb index 18a4a03..688b833 100644 --- a/lib/fintoc/transfers/resources/transfer.rb +++ b/lib/fintoc/transfers/resources/transfer.rb @@ -60,6 +60,11 @@ def to_s "#{direction_icon} #{amount_str} (#{@id}) - #{@status}" end + def refresh + fresh_transfer = @client.get_transfer(@id) + refresh_from_transfer(fresh_transfer) + end + def pending? @status == 'pending' end @@ -91,6 +96,32 @@ def inbound? def outbound? @direction == 'outbound' end + + private + + def refresh_from_transfer(transfer) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + raise 'Transfer must be the same instance' unless transfer.id == @id + + @object = transfer.object + @amount = transfer.amount + @currency = transfer.currency + @direction = transfer.direction + @status = transfer.status + @mode = transfer.mode + @post_date = transfer.post_date + @transaction_date = transfer.transaction_date + @comment = transfer.comment + @reference_id = transfer.reference_id + @receipt_url = transfer.receipt_url + @tracking_key = transfer.tracking_key + @return_reason = transfer.return_reason + @counterparty = transfer.counterparty + @account_number = transfer.account_number + @metadata = transfer.metadata + @created_at = transfer.created_at + + self + end end end end diff --git a/spec/lib/fintoc/transfers/transfer_spec.rb b/spec/lib/fintoc/transfers/transfer_spec.rb index ccffb6c..fc3cefe 100644 --- a/spec/lib/fintoc/transfers/transfer_spec.rb +++ b/spec/lib/fintoc/transfers/transfer_spec.rb @@ -212,4 +212,21 @@ end end end + + describe '#refresh' do + let(:refreshed_data) { data.merge(status: 'succeeded') } + let(:refreshed_transfer) { described_class.new(**refreshed_data) } + + before do + allow(client).to receive(:get_transfer).with(data[:id]).and_return(refreshed_transfer) + end + + it 'refreshes the transfer data' do + expect { transfer.refresh }.to change { transfer.status }.from('pending').to('succeeded') + end + + it 'returns self' do + expect(transfer.refresh).to eq(transfer) + end + end end From 419048f30e4e27a63977b3ebcff7a77214e2d48e Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Tue, 2 Sep 2025 18:33:55 -0400 Subject: [PATCH 5/6] feature(transfer): Add return_transfer method to transfer --- lib/fintoc/transfers/resources/transfer.rb | 5 +++++ spec/lib/fintoc/transfers/transfer_spec.rb | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/fintoc/transfers/resources/transfer.rb b/lib/fintoc/transfers/resources/transfer.rb index 688b833..5274192 100644 --- a/lib/fintoc/transfers/resources/transfer.rb +++ b/lib/fintoc/transfers/resources/transfer.rb @@ -65,6 +65,11 @@ def refresh refresh_from_transfer(fresh_transfer) end + def return_transfer + returned_transfer = @client.return_transfer(@id) + refresh_from_transfer(returned_transfer) + end + def pending? @status == 'pending' end diff --git a/spec/lib/fintoc/transfers/transfer_spec.rb b/spec/lib/fintoc/transfers/transfer_spec.rb index fc3cefe..8abe098 100644 --- a/spec/lib/fintoc/transfers/transfer_spec.rb +++ b/spec/lib/fintoc/transfers/transfer_spec.rb @@ -229,4 +229,22 @@ expect(transfer.refresh).to eq(transfer) end end + + describe '#return_transfer' do + let(:returned_data) { data.merge(status: 'return_pending') } + let(:returned_transfer) { described_class.new(**returned_data) } + + before do + allow(client).to receive(:return_transfer).with(data[:id]).and_return(returned_transfer) + end + + it 'returns the transfer and updates status' do + expect { transfer.return_transfer } + .to change { transfer.status }.from('pending').to('return_pending') + end + + it 'returns self' do + expect(transfer.return_transfer).to eq(transfer) + end + end end From 5c8df4104387df928649e14d4e5af6786329bab6 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Wed, 3 Sep 2025 10:54:02 -0400 Subject: [PATCH 6/6] refactor(client/jws): Move jws condition to separate method --- lib/fintoc/base_client.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/fintoc/base_client.rb b/lib/fintoc/base_client.rb index 9216961..ebe01f5 100644 --- a/lib/fintoc/base_client.rb +++ b/lib/fintoc/base_client.rb @@ -85,6 +85,10 @@ def build_url(resource, version: :v1) "#{Fintoc::Constants::SCHEME}#{base_url}#{resource}" end + def should_use_jws?(method, use_jws) + use_jws && @jws && %w[post patch put].include?(method.downcase) + end + def make_request(method, resource, parameters, version: :v1, use_jws: false) # this is to handle url returned in the link headers # I'm sure there is a better and more clever way to solve this @@ -94,7 +98,7 @@ def make_request(method, resource, parameters, version: :v1, use_jws: false) url = build_url(resource, version:) - if use_jws && @jws && %w[post patch put].include?(method.downcase) + if should_use_jws?(method, use_jws) request_body = parameters[:json]&.to_json || '' jws_signature = @jws.generate_signature(request_body)