From e76f534ce4d1c55bc727935a41c706fc793614f4 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Fri, 12 Sep 2025 13:35:04 -0300 Subject: [PATCH 1/9] refactor(PR-template): Remove accidental **** from PR template --- .github/pull_request_template.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cb0cd04..96014a7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -44,4 +44,3 @@ Ej.: ¿Es seguro hacer rollback? ¿Cuáles son los pasos para hacer rollback? -**** From 484e53e67be0d63e5f569454ee49c6c315043a02 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Fri, 12 Sep 2025 11:58:13 -0300 Subject: [PATCH 2/9] feature(base-client): Add support for idempotency key to base client --- lib/fintoc/base_client.rb | 33 +++-- spec/lib/fintoc/base_client_spec.rb | 210 +++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 13 deletions(-) diff --git a/lib/fintoc/base_client.rb b/lib/fintoc/base_client.rb index 1e9640c..3dc64ed 100644 --- a/lib/fintoc/base_client.rb +++ b/lib/fintoc/base_client.rb @@ -23,25 +23,25 @@ def initialize(api_key, jws_private_key: nil) end def get(version: :v1) - request('get', version: version) + request('get', version:) end def delete(version: :v1) - request('delete', version: version) + request('delete', version:) end - def post(version: :v1, use_jws: false) - request('post', version: version, use_jws: use_jws) + def post(version: :v1, use_jws: false, idempotency_key: nil) + request('post', version:, use_jws:, idempotency_key:) end - def patch(version: :v1, use_jws: false) - request('patch', version: version, use_jws: use_jws) + def patch(version: :v1, use_jws: false, idempotency_key: nil) + request('patch', version:, use_jws:, idempotency_key:) end - def request(method, version: :v1, use_jws: false) + def request(method, version: :v1, use_jws: false, idempotency_key: nil) proc do |resource, **kwargs| parameters = params(method, **kwargs) - response = make_request(method, resource, parameters, version: version, use_jws: use_jws) + response = make_request(method, resource, parameters, version:, use_jws:, idempotency_key:) content = JSON.parse(response.body, symbolize_names: true) if response.status.client_error? || response.status.server_error? @@ -91,7 +91,13 @@ 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) + def should_use_idempotency_key?(method, idempotency_key) + idempotency_key && %w[post patch put].include?(method.downcase) + end + + def make_request( + method, resource, parameters, version: :v1, use_jws: false, idempotency_key: nil + ) # 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' @@ -99,15 +105,20 @@ def make_request(method, resource, parameters, version: :v1, use_jws: false) end url = build_url(resource, version:) + request_client = client if should_use_jws?(method, use_jws) 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) + request_client = request_client.headers('Fintoc-JWS-Signature' => jws_signature) + end + + if should_use_idempotency_key?(method, idempotency_key) + request_client = request_client.headers('Idempotency-Key' => idempotency_key) end - client.send(method, url, parameters) + request_client.send(method, url, parameters) end def params(method, **kwargs) diff --git a/spec/lib/fintoc/base_client_spec.rb b/spec/lib/fintoc/base_client_spec.rb index 7735057..90901a9 100644 --- a/spec/lib/fintoc/base_client_spec.rb +++ b/spec/lib/fintoc/base_client_spec.rb @@ -39,6 +39,50 @@ post_request = client.post(version: :v1) expect(post_request).to be_a(Proc) end + + context 'when use_jws parameter is provided' do + before do + allow(client) + .to receive(:request).with('post', version: :v1, use_jws: true, idempotency_key: nil) + end + + it 'passes use_jws parameter to request' do + client.post(version: :v1, use_jws: true) + expect(client) + .to have_received(:request) + .with('post', version: :v1, use_jws: true, idempotency_key: nil) + end + end + + context 'when idempotency_key parameter is provided' do + let(:idempotency_key) { SecureRandom.uuid } + + before do + allow(client) + .to receive(:request).with('post', version: :v1, use_jws: false, idempotency_key:) + end + + it 'passes idempotency_key parameter to request' do + client.post(version: :v1, idempotency_key:) + expect(client) + .to have_received(:request).with('post', version: :v1, use_jws: false, idempotency_key:) + end + end + + context 'when both use_jws and idempotency_key parameters are provided' do + let(:idempotency_key) { SecureRandom.uuid } + + before do + allow(client) + .to receive(:request).with('post', version: :v1, use_jws: true, idempotency_key:) + end + + it 'passes both use_jws and idempotency_key parameters to request' do + client.post(version: :v1, use_jws: true, idempotency_key:) + expect(client) + .to have_received(:request).with('post', version: :v1, use_jws: true, idempotency_key:) + end + end end describe '#patch' do @@ -46,6 +90,52 @@ patch_request = client.patch(version: :v1) expect(patch_request).to be_a(Proc) end + + context 'when use_jws parameter is provided' do + before do + allow(client) + .to receive(:request).with('patch', version: :v1, use_jws: true, idempotency_key: nil) + end + + it 'passes use_jws parameter to request' do + client.patch(version: :v1, use_jws: true) + expect(client) + .to have_received(:request) + .with('patch', version: :v1, use_jws: true, idempotency_key: nil) + end + end + + context 'when idempotency_key parameter is provided' do + let(:idempotency_key) { SecureRandom.uuid } + + before do + allow(client) + .to receive(:request).with('patch', version: :v1, use_jws: false, idempotency_key:) + end + + it 'passes idempotency_key parameter to request' do + client.patch(version: :v1, idempotency_key:) + expect(client) + .to have_received(:request) + .with('patch', version: :v1, use_jws: false, idempotency_key:) + end + end + + context 'when both use_jws and idempotency_key parameters are provided' do + let(:idempotency_key) { SecureRandom.uuid } + + before do + allow(client) + .to receive(:request).with('patch', version: :v1, use_jws: true, idempotency_key:) + end + + it 'passes both use_jws and idempotency_key parameters to request' do + client.patch(version: :v1, use_jws: true, idempotency_key:) + expect(client) + .to have_received(:request) + .with('patch', version: :v1, use_jws: true, idempotency_key:) + end + end end describe '#request' do @@ -72,7 +162,7 @@ it 'returns parsed JSON content' do request_proc = client.request('get') - result = request_proc.call('/test/resource') + result = request_proc.call('test/resource') expect(result).to eq({ data: 'test_data' }) end @@ -105,10 +195,126 @@ it 'raises custom error from response' do request_proc = client.request('get') - expect { request_proc.call('/test/resource') } + expect { request_proc.call('test/resource') } .to raise_error(Fintoc::Errors::AuthenticationError, /Invalid API key/) end end + + context 'when idempotency_key is provided' do + let(:success_response_body) { { data: 'test_data' }.to_json } + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + let(:mock_http_client) { instance_double(HTTP::Client) } + + before do + allow(mock_response).to receive_messages( + body: success_response_body, + status: mock_status, + headers: mock_headers + ) + allow(mock_status).to receive_messages( + client_error?: false, + server_error?: false + ) + allow(mock_headers).to receive(:get).with('link').and_return(nil) + + allow(client).to receive(:make_request).and_call_original + allow(client).to receive(:client).and_return(mock_http_client) + allow(mock_http_client).to receive_messages(headers: mock_http_client, send: mock_response) + end + + it 'passes idempotency_key on POST request headers' do + client.request('post', idempotency_key:).call('test/resource', data: 'test') + + expect(mock_http_client) + .to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'passes idempotency_key on PATCH request headers' do + client.request('patch', idempotency_key:).call('test/resource', data: 'test') + + expect(mock_http_client) + .to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'does not pass idempotency_key on GET request headers' do + client.request('get').call('test/resource', param: 'value') + + expect(mock_http_client) + .not_to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'does not pass idempotency_key on DELETE request headers' do + client.request('delete').call('test/resource', param: 'value') + + expect(mock_http_client) + .not_to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + end + end + + describe '#make_request' do + let(:mock_http_client) { instance_double(HTTP::Client) } + let(:mock_response) { instance_double(HTTP::Response) } + + before do + allow(client).to receive(:client).and_return(mock_http_client) + allow(mock_http_client).to receive_messages( + headers: mock_http_client, + send: mock_response + ) + end + + context 'when idempotency_key is provided for supported methods' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + it 'adds Idempotency-Key header for POST requests' do + client.send(:make_request, 'post', '/test', {}, idempotency_key: idempotency_key) + + expect(mock_http_client) + .to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'adds Idempotency-Key header for PATCH requests' do + client.send(:make_request, 'patch', '/test', {}, idempotency_key: idempotency_key) + + expect(mock_http_client) + .to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'adds Idempotency-Key header for PUT requests' do + client.send(:make_request, 'put', '/test', {}, idempotency_key: idempotency_key) + + expect(mock_http_client) + .to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + end + + context 'when idempotency_key is provided for unsupported methods' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + it 'does not add Idempotency-Key header' do + client.send(:make_request, 'get', '/test', {}, idempotency_key: idempotency_key) + + expect(mock_http_client) + .not_to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + + it 'does not add Idempotency-Key header for DELETE requests' do + client.send(:make_request, 'delete', '/test', {}, idempotency_key: idempotency_key) + + expect(mock_http_client) + .not_to have_received(:headers).with('Idempotency-Key' => idempotency_key) + end + end + + context 'when no idempotency_key is provided' do + it 'does not add Idempotency-Key header' do + client.send(:make_request, 'post', '/test', {}) + + expect(mock_http_client) + .not_to have_received(:headers).with(hash_including('Idempotency-Key')) + end + end end describe '#to_s' do From 8fce797ee908318ad741d95d1c6749cd67ae9eaa Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 10:03:38 -0300 Subject: [PATCH 3/9] feature(accounts): Allow idempotency key to be passed on accounts and simulate methods --- lib/fintoc/v2/managers/accounts_manager.rb | 17 ++-- lib/fintoc/v2/managers/simulate_manager.rb | 10 ++- lib/fintoc/v2/resources/account.rb | 9 ++- spec/lib/fintoc/v2/account_spec.rb | 57 ++++++++++++- .../v2/managers/accounts_manager_spec.rb | 32 +++++++- .../v2/managers/simulate_manager_spec.rb | 16 +++- .../clients/accounts_client_examples.rb | 37 ++++++++- .../clients/simulate_client_examples.rb | 16 ++++ .../returns_an_Account_instance.yml | 79 +++++++++++++++++++ .../returns_an_updated_Account_instance.yml | 78 ++++++++++++++++++ ...eiving_a_transfer_with_idempotency_key.yml | 79 +++++++++++++++++++ 11 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_create/when_idempotency_key_is_provided/returns_an_Account_instance.yml create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_update/when_idempotency_key_is_provided/returns_an_updated_Account_instance.yml create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_simulate_manager/_simulate/_receive_transfer/with_idempotency_key/simulates_receiving_a_transfer_with_idempotency_key.yml diff --git a/lib/fintoc/v2/managers/accounts_manager.rb b/lib/fintoc/v2/managers/accounts_manager.rb index 6761f79..4b704ca 100644 --- a/lib/fintoc/v2/managers/accounts_manager.rb +++ b/lib/fintoc/v2/managers/accounts_manager.rb @@ -8,8 +8,8 @@ def initialize(client) @client = client end - def create(entity_id:, description:, **params) - data = _create_account(entity_id:, description:, **params) + def create(entity_id:, description:, idempotency_key: nil, **params) + data = _create_account(entity_id:, description:, idempotency_key:, **params) build_account(data) end @@ -22,15 +22,16 @@ def list(**params) _list_accounts(**params).map { |data| build_account(data) } end - def update(account_id, **params) - data = _update_account(account_id, **params) + def update(account_id, idempotency_key: nil, **params) + data = _update_account(account_id, idempotency_key:, **params) build_account(data) end private - def _create_account(entity_id:, description:, **params) - @client.post(version: :v2).call('accounts', entity_id:, description:, **params) + def _create_account(entity_id:, description:, idempotency_key: nil, **params) + @client.post(version: :v2, idempotency_key:) + .call('accounts', entity_id:, description:, **params) end def _get_account(account_id) @@ -41,8 +42,8 @@ def _list_accounts(**params) @client.get(version: :v2).call('accounts', **params) end - def _update_account(account_id, **params) - @client.patch(version: :v2).call("accounts/#{account_id}", **params) + def _update_account(account_id, idempotency_key: nil, **params) + @client.patch(version: :v2, idempotency_key:).call("accounts/#{account_id}", **params) end def build_account(data) diff --git a/lib/fintoc/v2/managers/simulate_manager.rb b/lib/fintoc/v2/managers/simulate_manager.rb index b9bbd03..e2301ad 100644 --- a/lib/fintoc/v2/managers/simulate_manager.rb +++ b/lib/fintoc/v2/managers/simulate_manager.rb @@ -8,16 +8,18 @@ def initialize(client) @client = client end - def receive_transfer(account_number_id:, amount:, currency:) - data = _simulate_receive_transfer(account_number_id:, amount:, currency:) + def receive_transfer(account_number_id:, amount:, currency:, idempotency_key: nil) + data = _simulate_receive_transfer( + account_number_id:, amount:, currency:, idempotency_key: + ) build_transfer(data) end private - def _simulate_receive_transfer(account_number_id:, amount:, currency:) + def _simulate_receive_transfer(account_number_id:, amount:, currency:, idempotency_key: nil) @client - .post(version: :v2) + .post(version: :v2, idempotency_key:) .call('simulate/receive_transfer', account_number_id:, amount:, currency:) end diff --git a/lib/fintoc/v2/resources/account.rb b/lib/fintoc/v2/resources/account.rb index 9601a71..327b518 100644 --- a/lib/fintoc/v2/resources/account.rb +++ b/lib/fintoc/v2/resources/account.rb @@ -44,11 +44,11 @@ def refresh refresh_from_account(fresh_account) end - def update(description: nil) + def update(description: nil, idempotency_key: nil) params = {} params[:description] = description if description - updated_account = @client.accounts.update(@id, **params) + updated_account = @client.accounts.update(@id, idempotency_key:, **params) refresh_from_account(updated_account) end @@ -68,7 +68,7 @@ def test_mode? @mode == 'test' end - def simulate_receive_transfer(amount:) + def simulate_receive_transfer(amount:, idempotency_key: nil) unless test_mode? raise Fintoc::Errors::InvalidRequestError, 'Simulation is only available in test mode' end @@ -76,7 +76,8 @@ def simulate_receive_transfer(amount:) @client.simulate.receive_transfer( account_number_id: @root_account_number_id, amount:, - currency: @currency + currency: @currency, + idempotency_key: ) end diff --git a/spec/lib/fintoc/v2/account_spec.rb b/spec/lib/fintoc/v2/account_spec.rb index 956af9c..c1fac89 100644 --- a/spec/lib/fintoc/v2/account_spec.rb +++ b/spec/lib/fintoc/v2/account_spec.rb @@ -153,7 +153,7 @@ expect(client.accounts) .to have_received(:update) - .with('acc_123', description: 'New account description') + .with('acc_123', description: 'New account description', idempotency_key: nil) expect(account.description).to eq('New account description') end @@ -162,7 +162,19 @@ account.update(description: 'Test description') expect(client.accounts) .to have_received(:update) - .with('acc_123', description: 'Test description') + .with('acc_123', description: 'Test description', idempotency_key: nil) + end + + context 'with idempotency key' do + let(:idempotency_key) { 'account_update_123' } + + it 'passes idempotency_key to the manager update method' do + account.update(description: 'New description', idempotency_key:) + + expect(client.accounts) + .to have_received(:update) + .with('acc_123', description: 'New description', idempotency_key:) + end end end @@ -187,14 +199,53 @@ .with( account_number_id: account.root_account_number_id, amount: 10000, - currency: account.currency + currency: account.currency, + idempotency_key: nil ) .and_return(expected_transfer) end it 'simulates receiving a transfer using account currency' do result = account.simulate_receive_transfer(amount: 10000) + expect(result).to eq(expected_transfer) + + expect(client.simulate) + .to have_received(:receive_transfer) + .with( + account_number_id: account.root_account_number_id, + amount: 10000, + currency: account.currency, + idempotency_key: nil + ) + end + + context 'with idempotency key' do + let(:idempotency_key) { 'simulation_123' } + + before do + allow(client.simulate) + .to receive(:receive_transfer) + .with( + account_number_id: account.root_account_number_id, + amount: 10000, + currency: account.currency, + idempotency_key: + ) + .and_return(expected_transfer) + end + + it 'passes idempotency_key to simulate method' do + result = account.simulate_receive_transfer(amount: 10000, idempotency_key:) + + expect(client.simulate).to have_received(:receive_transfer).with( + account_number_id: account.root_account_number_id, + amount: 10000, + currency: account.currency, + idempotency_key: + ) + expect(result).to eq(expected_transfer) + end end end diff --git a/spec/lib/fintoc/v2/managers/accounts_manager_spec.rb b/spec/lib/fintoc/v2/managers/accounts_manager_spec.rb index a13e1df..88a1de1 100644 --- a/spec/lib/fintoc/v2/managers/accounts_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/accounts_manager_spec.rb @@ -52,8 +52,8 @@ before do allow(client).to receive(:get).with(version: :v2).and_return(get_proc) - allow(client).to receive(:post).with(version: :v2).and_return(post_proc) - allow(client).to receive(:patch).with(version: :v2).and_return(patch_proc) + allow(client).to receive(:post).with(version: :v2, idempotency_key: nil).and_return(post_proc) + allow(client).to receive(:patch).with(version: :v2, idempotency_key: nil).and_return(patch_proc) allow(get_proc) .to receive(:call) @@ -81,6 +81,20 @@ expect(Fintoc::V2::Account) .to have_received(:new).with(**first_account_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:post).with(version: :v2, idempotency_key:).and_return(post_proc) + end + + it 'passes idempotency_key to the POST method' do + manager.create(entity_id:, description: 'My account', idempotency_key:) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + end + end end describe '#get' do @@ -107,5 +121,19 @@ expect(Fintoc::V2::Account) .to have_received(:new).with(**updated_account_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:patch).with(version: :v2, idempotency_key:).and_return(patch_proc) + end + + it 'passes idempotency_key to the PATCH method' do + manager.update(account_id, name: 'Updated name', idempotency_key:) + + expect(client).to have_received(:patch).with(version: :v2, idempotency_key:) + end + end end end diff --git a/spec/lib/fintoc/v2/managers/simulate_manager_spec.rb b/spec/lib/fintoc/v2/managers/simulate_manager_spec.rb index 7e289d7..7ca359c 100644 --- a/spec/lib/fintoc/v2/managers/simulate_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/simulate_manager_spec.rb @@ -19,7 +19,7 @@ end before do - allow(client).to receive(:post).with(version: :v2).and_return(post_proc) + allow(client).to receive(:post).with(version: :v2, idempotency_key: nil).and_return(post_proc) allow(post_proc) .to receive(:call) @@ -35,5 +35,19 @@ expect(Fintoc::V2::Transfer).to have_received(:new).with(**transfer_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:post).with(version: :v2, idempotency_key:).and_return(post_proc) + end + + it 'passes idempotency_key to the POST method' do + manager.receive_transfer(account_number_id:, amount:, currency:, idempotency_key:) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + end + end end end diff --git a/spec/support/shared_examples/clients/accounts_client_examples.rb b/spec/support/shared_examples/clients/accounts_client_examples.rb index d8d5943..db1c1e0 100644 --- a/spec/support/shared_examples/clients/accounts_client_examples.rb +++ b/spec/support/shared_examples/clients/accounts_client_examples.rb @@ -25,6 +25,23 @@ status: 'active' ) end + + context 'when idempotency key is provided' do + let(:idempotency_key) { 'test_account_creation_123' } + + it 'returns an Account instance', :vcr do + account = + client.accounts.create(entity_id:, description: 'Test account', idempotency_key:) + + expect(account) + .to be_an_instance_of(Fintoc::V2::Account) + .and have_attributes( + description: 'Test account', + currency: 'MXN', + status: 'active' + ) + end + end end describe '#get' do @@ -51,8 +68,9 @@ end describe '#update' do + let(:updated_description) { 'Updated account description' } + it 'returns an updated Account instance', :vcr do - updated_description = 'Updated account description' account = client.accounts.update(account_id, description: updated_description) expect(account) @@ -62,6 +80,23 @@ description: updated_description ) end + + context 'when idempotency key is provided' do + let(:idempotency_key) { 'test_account_update_123' } + let(:patch_proc) { instance_double(Proc) } + + it 'returns an updated Account instance', :vcr do + account = + client.accounts.update(account_id, description: updated_description, idempotency_key:) + + expect(account) + .to be_an_instance_of(Fintoc::V2::Account) + .and have_attributes( + id: account_id, + description: updated_description + ) + end + end end end end diff --git a/spec/support/shared_examples/clients/simulate_client_examples.rb b/spec/support/shared_examples/clients/simulate_client_examples.rb index ff731be..4e3fb76 100644 --- a/spec/support/shared_examples/clients/simulate_client_examples.rb +++ b/spec/support/shared_examples/clients/simulate_client_examples.rb @@ -27,6 +27,22 @@ account_number: include(id: simulate_transfer_data[:account_number_id]) ) end + + context 'with idempotency key' do + let(:idempotency_key) { 'test_simulation_123' } + + it 'simulates receiving a transfer with idempotency key', :vcr do + transfer = client.simulate.receive_transfer(**simulate_transfer_data, idempotency_key:) + + expect(transfer) + .to be_an_instance_of(Fintoc::V2::Transfer) + .and have_attributes( + amount: simulate_transfer_data[:amount], + currency: simulate_transfer_data[:currency], + account_number: include(id: simulate_transfer_data[:account_number_id]) + ) + end + end end end end diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_create/when_idempotency_key_is_provided/returns_an_Account_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_create/when_idempotency_key_is_provided/returns_an_Account_instance.yml new file mode 100644 index 0000000..f8ffbd1 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_create/when_idempotency_key_is_provided/returns_an_Account_instance.yml @@ -0,0 +1,79 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/accounts + body: + encoding: UTF-8 + string: '{"entity_id":"ent_31t0VhhrAXASFQTVYfCfIBnljbT","description":"Test + account"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Idempotency-Key: + - test_account_creation_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 201 + message: Created + headers: + Date: + - Mon, 15 Sep 2025 13:02:36 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '383' + Connection: + - close + Cf-Ray: + - 97f851c30eccc706-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/"ffdc21c1fedd3ad600d0a04df1a2302a" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - c2470f57-6714-4fea-911b-420c3bd0b0cc + X-Runtime: + - '0.179155' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=x7fNZsiPJEujum%2BTgV%2FehRc5GWZqcrr5Ie5LYD1iq8lUaKo3Crtv3KSsCBShWeshzT7xMjPcPtsGFuUsUSmTh5UkOzlOVHKTlo%2FnqNsd%2BobK%2Bhw7pSZRMKTBFtq8jazQ"}],"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=31219&min_rtt=30645&rtt_var=11902&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=1979&delivery_rate=139011&cwnd=252&unsent_bytes=0&cid=cc86a50e6b675957&ts=431&x=0" + body: + encoding: UTF-8 + string: '{"id":"acc_32jkQxmBIHjvqudw7sBsfW2Jx1i","object":"account","mode":"test","root_account_number":"735969000000204461","is_root":false,"root_account_number_id":"acno_32jkR0JJSQtI77ClnTRaH2g3uwh","available_balance":0,"currency":"MXN","entity":{"id":"ent_31t0VhhrAXASFQTVYfCfIBnljbT","holder_name":"Fintoc","holder_id":"ND","is_root":true},"description":"Test + account","status":"active"}' + recorded_at: Mon, 15 Sep 2025 13:02:36 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_update/when_idempotency_key_is_provided/returns_an_updated_Account_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_update/when_idempotency_key_is_provided/returns_an_updated_Account_instance.yml new file mode 100644 index 0000000..94cb92c --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_accounts_manager/_accounts/_update/when_idempotency_key_is_provided/returns_an_updated_Account_instance.yml @@ -0,0 +1,78 @@ +--- +http_interactions: +- request: + method: patch + uri: https://api.fintoc.com/v2/accounts/acc_31yYL7h9LVPg121AgFtCyJPDsgM + body: + encoding: UTF-8 + string: '{"description":"Updated account description"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Idempotency-Key: + - test_account_update_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 15 Sep 2025 12:40:28 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '405' + Connection: + - close + Cf-Ray: + - 97f83159ff7e1ebd-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/"1ecc1054245bad66eb5094801931e2ec" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 8aeb79a6-543f-4104-a225-ec57fc8343e4 + X-Runtime: + - '0.063118' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=LVGZoCkr9CcGmxC46v6SP62xJk4eFL2RZakuZU4wSuK8iG86sT3cmOaXVTRH08AxeSF%2BPU5pMsBDEVrbLLJ0B8gWEtNb1ogVQCUuFIEd%2FtI6QLCRLCquZjm%2BIcrSiXU1"}],"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=30935&min_rtt=30573&rtt_var=11723&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=1979&delivery_rate=139338&cwnd=252&unsent_bytes=0&cid=a203557fb8dc96be&ts=334&x=0" + body: + encoding: UTF-8 + string: '{"id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","object":"account","mode":"test","root_account_number":"735969000000203297","is_root":false,"root_account_number_id":"acno_31yYL5uOb39vzvRIszPhoXYMjt4","available_balance":21960000,"currency":"MXN","entity":{"id":"ent_31t0VhhrAXASFQTVYfCfIBnljbT","holder_name":"Fintoc","holder_id":"ND","is_root":true},"description":"Updated + account description","status":"active"}' + recorded_at: Mon, 15 Sep 2025 12:40:28 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_simulate_manager/_simulate/_receive_transfer/with_idempotency_key/simulates_receiving_a_transfer_with_idempotency_key.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_simulate_manager/_simulate/_receive_transfer/with_idempotency_key/simulates_receiving_a_transfer_with_idempotency_key.yml new file mode 100644 index 0000000..02a3d32 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_simulate_manager/_simulate/_receive_transfer/with_idempotency_key/simulates_receiving_a_transfer_with_idempotency_key.yml @@ -0,0 +1,79 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/simulate/receive_transfer + body: + encoding: UTF-8 + string: '{"account_number_id":"acno_326dzRGqxLee3j9TkaBBBMfs2i0","amount":10000,"currency":"MXN"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Idempotency-Key: + - test_simulation_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 201 + message: Created + headers: + Date: + - Mon, 15 Sep 2025 13:04:27 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '924' + Connection: + - close + Cf-Ray: + - 97f854777c1d1e84-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/"82c29e7cfa8f11f60f76c397c650754d" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 2eef2bf6-c1dc-4498-8334-e4ca4aacb02a + X-Runtime: + - '0.547414' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=UPnsx0ZpXiDFlWdbm4r7LawvP8CIv7KzGUMFzl3VXcbVnonFrn0%2FTPQrwXSe8HoJXpusq%2B%2FDJhwSv9X2EtcQKhoOTYKu%2BBhBEHd2Po28wwowjZagAy9%2F1Fsuertqek8x"}],"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=32249&min_rtt=31721&rtt_var=12273&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=2002&delivery_rate=134295&cwnd=249&unsent_bytes=0&cid=7e19854c47060958&ts=836&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_32jkevdbJyoqZeJpt3s2zSeGsy9","amount":10000,"currency":"MXN","direction":"inbound","status":"pending","transaction_date":"2025-09-15T13:04:26Z","post_date":"2025-09-15T00:00:00Z","comment":null,"reference_id":"1","tracking_key":"202509159073500000000000000001","receipt_url":null,"mode":"test","counterparty":{"holder_id":"JNDD471123XJ4","holder_name":"Carson + Runolfsson","account_number":"723969738291465733","account_type":"clabe","institution":{"id":"90723","name":"Cuenca","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: Mon, 15 Sep 2025 13:04:27 GMT +recorded_with: VCR 6.3.1 From 2022140a603349f07cd9ce52a9379167cf7ca732 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 10:58:02 -0300 Subject: [PATCH 4/9] feature(transfers): Add idempotency key to transfer methods --- lib/fintoc/v2/managers/transfers_manager.rb | 21 +++-- lib/fintoc/v2/resources/transfer.rb | 4 +- .../v2/managers/transfers_manager_spec.rb | 78 ++++++++++++++++- spec/lib/fintoc/v2/transfer_spec.rb | 18 +++- .../clients/transfers_client_examples.rb | 34 ++++++++ .../returns_a_Transfer_instance.yml | 83 +++++++++++++++++++ ...er_instance_with_return_pending_status.yml | 81 ++++++++++++++++++ 7 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_create/with_idempotency_key/returns_a_Transfer_instance.yml create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_return/with_idempotency_key/returns_a_Transfer_instance_with_return_pending_status.yml diff --git a/lib/fintoc/v2/managers/transfers_manager.rb b/lib/fintoc/v2/managers/transfers_manager.rb index adb5a5c..e9a049b 100644 --- a/lib/fintoc/v2/managers/transfers_manager.rb +++ b/lib/fintoc/v2/managers/transfers_manager.rb @@ -8,8 +8,10 @@ def initialize(client) @client = client end - def create(amount:, currency:, account_id:, counterparty:, **params) - data = _create_transfer(amount:, currency:, account_id:, counterparty:, **params) + def create(amount:, currency:, account_id:, counterparty:, idempotency_key: nil, **params) + data = _create_transfer( + amount:, currency:, account_id:, counterparty:, idempotency_key:, **params + ) build_transfer(data) end @@ -22,16 +24,18 @@ def list(**params) _list_transfers(**params).map { |data| build_transfer(data) } end - def return(transfer_id) - data = _return_transfer(transfer_id) + def return(transfer_id, idempotency_key: nil) + data = _return_transfer(transfer_id, idempotency_key:) build_transfer(data) end private - def _create_transfer(amount:, currency:, account_id:, counterparty:, **params) + def _create_transfer( + amount:, currency:, account_id:, counterparty:, idempotency_key: nil, **params + ) @client - .post(version: :v2, use_jws: true) + .post(version: :v2, use_jws: true, idempotency_key:) .call('transfers', amount:, currency:, account_id:, counterparty:, **params) end @@ -43,8 +47,9 @@ def _list_transfers(**params) @client.get(version: :v2).call('transfers', **params) end - def _return_transfer(transfer_id) - @client.post(version: :v2, use_jws: true).call('transfers/return', transfer_id:) + def _return_transfer(transfer_id, idempotency_key: nil) + @client.post(version: :v2, use_jws: true, idempotency_key:) + .call('transfers/return', transfer_id:) end def build_transfer(data) diff --git a/lib/fintoc/v2/resources/transfer.rb b/lib/fintoc/v2/resources/transfer.rb index 0d78514..921b609 100644 --- a/lib/fintoc/v2/resources/transfer.rb +++ b/lib/fintoc/v2/resources/transfer.rb @@ -62,8 +62,8 @@ def refresh refresh_from_transfer(fresh_transfer) end - def return_transfer - returned_transfer = @client.transfers.return(@id) + def return_transfer(idempotency_key: nil) + returned_transfer = @client.transfers.return(@id, idempotency_key:) refresh_from_transfer(returned_transfer) end diff --git a/spec/lib/fintoc/v2/managers/transfers_manager_spec.rb b/spec/lib/fintoc/v2/managers/transfers_manager_spec.rb index ef1ebdf..74ce5ae 100644 --- a/spec/lib/fintoc/v2/managers/transfers_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/transfers_manager_spec.rb @@ -43,7 +43,7 @@ before do allow(client).to receive(:get).with(version: :v2).and_return(get_proc) - allow(client).to receive(:post).with(version: :v2, use_jws: true).and_return(post_proc) + allow(client).to receive(:post).and_return(post_proc) allow(get_proc) .to receive(:call) @@ -77,6 +77,53 @@ expect(Fintoc::V2::Transfer) .to have_received(:new).with(**first_transfer_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client) + .to receive(:post) + .with(version: :v2, use_jws: true, idempotency_key:) + .and_return(post_proc) + allow(post_proc) + .to receive(:call) + .with( + 'transfers', + amount: 10000, + currency: 'MXN', + account_id: 'acc_123', + counterparty: + ) + .and_return(first_transfer_data) + end + + it 'passes idempotency_key to the POST method' do + manager.create( + amount: 10000, currency: 'MXN', account_id: 'acc_123', counterparty:, idempotency_key: + ) + + expect(client).to have_received(:post).with(version: :v2, use_jws: true, idempotency_key:) + expect(post_proc) + .to have_received(:call) + .with( + 'transfers', amount: 10000, currency: 'MXN', account_id: 'acc_123', counterparty: + ) + end + + it 'builds transfer with the response' do + manager.create( + amount: 10000, + currency: 'MXN', + account_id: 'acc_123', + counterparty:, + idempotency_key: + ) + + expect(Fintoc::V2::Transfer) + .to have_received(:new).with(**first_transfer_data, client:) + end + end end describe '#get' do @@ -103,5 +150,34 @@ expect(Fintoc::V2::Transfer) .to have_received(:new).with(**returned_transfer_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client) + .to receive(:post) + .with(version: :v2, use_jws: true, idempotency_key:) + .and_return(post_proc) + allow(post_proc) + .to receive(:call) + .with('transfers/return', transfer_id:) + .and_return(returned_transfer_data) + end + + it 'passes idempotency_key to the POST method' do + manager.return('trf_123', idempotency_key:) + + expect(client).to have_received(:post).with(version: :v2, use_jws: true, idempotency_key:) + expect(post_proc).to have_received(:call).with('transfers/return', transfer_id:) + end + + it 'builds transfer with the response' do + manager.return('trf_123', idempotency_key:) + + expect(Fintoc::V2::Transfer) + .to have_received(:new).with(**returned_transfer_data, client:) + end + end end end diff --git a/spec/lib/fintoc/v2/transfer_spec.rb b/spec/lib/fintoc/v2/transfer_spec.rb index a3ca8e2..8704759 100644 --- a/spec/lib/fintoc/v2/transfer_spec.rb +++ b/spec/lib/fintoc/v2/transfer_spec.rb @@ -245,7 +245,8 @@ let(:returned_transfer) { described_class.new(**returned_data) } before do - allow(client.transfers).to receive(:return).with(data[:id]).and_return(returned_transfer) + allow(client.transfers) + .to receive(:return).with(data[:id], idempotency_key: nil).and_return(returned_transfer) end it 'returns the transfer and updates status' do @@ -257,5 +258,20 @@ expect(transfer.return_transfer).to eq(transfer) expect(transfer.status).to eq('return_pending') end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client.transfers) + .to receive(:return).with(data[:id], idempotency_key:).and_return(returned_transfer) + end + + it 'passes idempotency_key to the return method' do + transfer.return_transfer(idempotency_key:) + + expect(client.transfers).to have_received(:return).with(data[:id], idempotency_key:) + end + end end end diff --git a/spec/support/shared_examples/clients/transfers_client_examples.rb b/spec/support/shared_examples/clients/transfers_client_examples.rb index 03f5fff..a30ba53 100644 --- a/spec/support/shared_examples/clients/transfers_client_examples.rb +++ b/spec/support/shared_examples/clients/transfers_client_examples.rb @@ -81,6 +81,24 @@ status: 'pending' ) end + + context 'with idempotency key' do + let(:idempotency_key) { 'test_transfer_creation_123' } + + it 'returns a Transfer instance', :vcr do + transfer = client.transfers.create(**transfer_data, idempotency_key:) + + expect(transfer) + .to be_an_instance_of(Fintoc::V2::Transfer) + .and have_attributes( + amount: 50000, + currency: 'MXN', + comment: 'Test payment', + reference_id: '123456', + status: 'pending' + ) + end + end end describe '#get' do @@ -125,6 +143,22 @@ status: 'return_pending' ) end + + context 'with idempotency key' do + let(:transfer_id) { 'tr_32jkevdbJyoqZeJpt3s2zSeGsy9' } + let(:idempotency_key) { 'test_transfer_return_456' } + + it 'returns a Transfer instance with return_pending status', :vcr do + transfer = client.transfers.return(transfer_id, idempotency_key:) + + expect(transfer) + .to be_an_instance_of(Fintoc::V2::Transfer) + .and have_attributes( + id: transfer_id, + status: 'return_pending' + ) + end + end end end end diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_create/with_idempotency_key/returns_a_Transfer_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_create/with_idempotency_key/returns_a_Transfer_instance.yml new file mode 100644 index 0000000..7168194 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_create/with_idempotency_key/returns_a_Transfer_instance.yml @@ -0,0 +1,83 @@ +--- +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/1.0.0 + Fintoc-Jws-Signature: + - eyJhbGciOiJSUzI1NiIsIm5vbmNlIjoiOWEyOWY3MmI3OTMxMWE2ZDNlZjM5ZDVhMzMyNzQ1NmYiLCJ0cyI6MTc1Nzk0NDI2MywiY3JpdCI6WyJ0cyIsIm5vbmNlIl19.cC0eHmWgksOdwQohfq7txkpzl3BtoeH5S0dtVc7eyv81g36rqDPdgD7HAU_ValT5yM2ipIPtq9Nxj8PmJeVfWHWQ8v2w-Xh6ZyPSpc4sLmUIt75PqixnMxpEGd2zR9gUssQeuoZ59zpiaLZ7eXt4mRoabz0NDz8w_N6ytkJ5loq2XsLKF_U-wqChc3OkUt5PrZNLkIWl0F5acIRd9PuMwBv7f-O0OTyZj3_xCHQtOdORD9iflm2NajSpYb3sMnT73_x1D23qzSLqEUM6XXLeE0LwAuDyQowLoi0Hb7_LMVym-ioWoKKwiFFXspViY-_uzlAqsFbckRjGRnnVaZkFZQ + Idempotency-Key: + - test_transfer_creation_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 201 + message: Created + headers: + Date: + - Mon, 15 Sep 2025 13:51:04 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '863' + Connection: + - close + Cf-Ray: + - 97f898bf3eca1fe4-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/"a751b58856b241a654dc45766e2d1948" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - c9f02a87-5dfb-468e-abf7-0865533d3c18 + X-Runtime: + - '0.835700' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=3WN9NQj%2FJ%2BlBOprWo5sd%2BPJ3k%2FYnlg3JtOou1hewfv2U4XD%2BGtV3DtzwRRFxFinURzf7ijnIMvsCvJ1SJxiUk9%2FXFb3ovUbThVfu498Jlvr%2BYts7%2BFLGuhhSU3C4X3vg"}],"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=47099&min_rtt=32430&rtt_var=22639&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=2686&delivery_rate=131359&cwnd=252&unsent_bytes=0&cid=e26ee8c1ed576517&ts=1104&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_32jqKMMm1XFaPPxmdk5xg7Q1yHd","amount":50000,"currency":"MXN","direction":"outbound","status":"pending","transaction_date":"2025-09-15T13:51:03Z","post_date":null,"comment":"Test + payment","reference_id":"123456","tracking_key":"202509159073500000000000000002","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: Mon, 15 Sep 2025 13:51:04 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_return/with_idempotency_key/returns_a_Transfer_instance_with_return_pending_status.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_return/with_idempotency_key/returns_a_Transfer_instance_with_return_pending_status.yml new file mode 100644 index 0000000..eee37b2 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_transfers_manager/_transfers/_return/with_idempotency_key/returns_a_Transfer_instance_with_return_pending_status.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/transfers/return + body: + encoding: UTF-8 + string: '{"transfer_id":"tr_32jkevdbJyoqZeJpt3s2zSeGsy9"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Fintoc-Jws-Signature: + - eyJhbGciOiJSUzI1NiIsIm5vbmNlIjoiZDgxZGUyNGY4ZmQxYzdmNWFmZDUxOGVhOWUyZWI5ZGMiLCJ0cyI6MTc1Nzk0NDU1OCwiY3JpdCI6WyJ0cyIsIm5vbmNlIl19.HmIvMqtEar3l8rQFNdPI21mF5164i0qMTeGLErpz8TWWSnrX9GXmbCpiWFkD4fJqEa6NUJ3exif-KVfkiJXa1FwPk4_3thIWrN9Lq0iDcQkvdwBnfnJ6OdrL9aRLl2ELA2wLifJ9KexVRpux3-3_RBeLL-vlsjrrvRoPj2viNwQ_cbaEj3zkjKpkDzI8lA8lRBsxlhvMlkXDwuV-OB2WYo9CYr19yVi-ARrOVhaghl5QV_vhe_WfZ2xpz_og_SLLvsWZY4fh3HVq2DKaQx1WmyMVkFN1QNlJLYwNR3UMdt1qZe9Q28JPC_BfEq9tP_dVF8DqDTSj6fTNosQ-vgDfuw + Idempotency-Key: + - test_transfer_return_456 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 15 Sep 2025 13:55:59 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '1110' + Connection: + - close + Cf-Ray: + - 97f89ff5fe0da58f-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/"76f4dc6f63d1e1fdf92bec1447c410e2" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 17ff22d1-10a9-46f6-9ada-916afa5d3cc6 + X-Runtime: + - '0.413592' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=OgTlE2h0ho%2BXLVi5s2MJ0nKmMujCZg2iz9s9%2BiV9PCrjsTAcSSGzpKqENFHyX5X%2BukOpNU%2BpwMA1K80aKPycbai89w5WurMqY93lKOB7fEGpS0EoaZhtvcLl8HKb5x%2Fh"}],"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=33672&min_rtt=32892&rtt_var=13894&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3878&recv_bytes=2453&delivery_rate=108859&cwnd=252&unsent_bytes=0&cid=1184fff854a324b2&ts=679&x=0" + body: + encoding: UTF-8 + string: '{"object":"transfer","id":"tr_32jkevdbJyoqZeJpt3s2zSeGsy9","amount":10000,"currency":"MXN","direction":"inbound","status":"return_pending","transaction_date":"2025-09-15T13:04:26Z","post_date":"2025-09-15T00:00:00Z","comment":null,"reference_id":"1","tracking_key":"202509159073500000000000000001","receipt_url":"https://www.banxico.org.mx/cep-beta/go?i=90735\u0026s=dummy\u0026d=HDsSmhWT0QkFhcGMm9sAq%2BnmbMxcNdhT6LV9ghYzNfLO%2BAdWPo6Gh2PvKldLImn7BkerqSEhyimhUXEwGU6Gb8gh2K43RCLMEI4bVNyEHxw%3D","mode":"test","counterparty":{"holder_id":"JNDD471123XJ4","holder_name":"Carson + Runolfsson","account_number":"723969738291465733","account_type":"clabe","institution":{"id":"90723","name":"Cuenca","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: Mon, 15 Sep 2025 13:55:59 GMT +recorded_with: VCR 6.3.1 From c60466250539125032757caf7391a6ddd59d0798 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 11:17:01 -0300 Subject: [PATCH 5/9] feature(account-numbers): Add idempotency key to account numbers methods --- .../v2/managers/account_numbers_manager.rb | 21 +++-- lib/fintoc/v2/resources/account_number.rb | 9 ++- spec/lib/fintoc/v2/account_number_spec.rb | 54 ++++++++++++- .../managers/account_numbers_manager_spec.rb | 39 ++++++++- .../account_numbers_client_examples.rb | 39 ++++++++- .../returns_an_AccountNumber_instance.yml | 79 +++++++++++++++++++ ...urns_an_updated_AccountNumber_instance.yml | 78 ++++++++++++++++++ 7 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_create/with_idempotency_key/returns_an_AccountNumber_instance.yml create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_update/with_idempotency_key/returns_an_updated_AccountNumber_instance.yml diff --git a/lib/fintoc/v2/managers/account_numbers_manager.rb b/lib/fintoc/v2/managers/account_numbers_manager.rb index 58ec4e7..a7ffd33 100644 --- a/lib/fintoc/v2/managers/account_numbers_manager.rb +++ b/lib/fintoc/v2/managers/account_numbers_manager.rb @@ -8,8 +8,10 @@ def initialize(client) @client = client end - def create(account_id:, description: nil, metadata: nil, **params) - data = _create_account_number(account_id:, description:, metadata:, **params) + def create(account_id:, description: nil, metadata: nil, idempotency_key: nil, **params) + data = _create_account_number( + account_id:, description:, metadata:, idempotency_key:, **params + ) build_account_number(data) end @@ -22,20 +24,22 @@ def list(**params) _list_account_numbers(**params).map { |data| build_account_number(data) } end - def update(account_number_id, **params) - data = _update_account_number(account_number_id, **params) + def update(account_number_id, idempotency_key: nil, **params) + data = _update_account_number(account_number_id, idempotency_key:, **params) build_account_number(data) end private - def _create_account_number(account_id:, description: nil, metadata: nil, **params) + def _create_account_number( + account_id:, description: nil, metadata: nil, idempotency_key: nil, **params + ) request_params = { account_id: } request_params[:description] = description if description request_params[:metadata] = metadata if metadata request_params.merge!(params) - @client.post(version: :v2).call('account_numbers', **request_params) + @client.post(version: :v2, idempotency_key:).call('account_numbers', **request_params) end def _get_account_number(account_number_id) @@ -46,8 +50,9 @@ def _list_account_numbers(**params) @client.get(version: :v2).call('account_numbers', **params) end - def _update_account_number(account_number_id, **params) - @client.patch(version: :v2).call("account_numbers/#{account_number_id}", **params) + def _update_account_number(account_number_id, idempotency_key: nil, **params) + @client.patch(version: :v2, idempotency_key:) + .call("account_numbers/#{account_number_id}", **params) end def build_account_number(data) diff --git a/lib/fintoc/v2/resources/account_number.rb b/lib/fintoc/v2/resources/account_number.rb index a0e4df1..3dac238 100644 --- a/lib/fintoc/v2/resources/account_number.rb +++ b/lib/fintoc/v2/resources/account_number.rb @@ -42,13 +42,13 @@ def refresh refresh_from_account_number(fresh_account_number) end - def update(description: nil, status: nil, metadata: nil) + def update(description: nil, status: nil, metadata: nil, idempotency_key: nil) params = {} params[:description] = description if description params[:status] = status if status params[:metadata] = metadata if metadata - updated_account_number = @client.account_numbers.update(@id, **params) + updated_account_number = @client.account_numbers.update(@id, idempotency_key:, **params) refresh_from_account_number(updated_account_number) end @@ -68,7 +68,7 @@ def test_mode? @mode == 'test' end - def simulate_receive_transfer(amount:, currency: 'MXN') + def simulate_receive_transfer(amount:, currency: 'MXN', idempotency_key: nil) unless test_mode? raise Fintoc::Errors::InvalidRequestError, 'Simulation is only available in test mode' end @@ -76,7 +76,8 @@ def simulate_receive_transfer(amount:, currency: 'MXN') @client.simulate.receive_transfer( account_number_id: @id, amount:, - currency: + currency:, + idempotency_key: ) end diff --git a/spec/lib/fintoc/v2/account_number_spec.rb b/spec/lib/fintoc/v2/account_number_spec.rb index 4446f77..199ad2c 100644 --- a/spec/lib/fintoc/v2/account_number_spec.rb +++ b/spec/lib/fintoc/v2/account_number_spec.rb @@ -170,9 +170,24 @@ account_number.id, description: new_description, status: new_status, - metadata: new_metadata + metadata: new_metadata, + idempotency_key: nil ) end + + context 'with idempotency key' do + let(:idempotency_key) { 'account_number_update_123' } + + it 'passes idempotency_key to the manager update method' do + account_number.update(description: new_description, idempotency_key:) + + expect(client.account_numbers).to have_received(:update).with( + account_number.id, + description: new_description, + idempotency_key: + ) + end + end end describe '#test_mode?' do @@ -193,7 +208,12 @@ before do allow(client.simulate) .to receive(:receive_transfer) - .with(account_number_id: account_number.id, amount: 10000, currency: 'MXN') + .with( + account_number_id: account_number.id, + amount: 10000, + currency: 'MXN', + idempotency_key: nil + ) .and_return(expected_transfer) end @@ -201,6 +221,36 @@ result = account_number.simulate_receive_transfer(amount: 10000) expect(result).to eq(expected_transfer) end + + context 'with idempotency key' do + let(:idempotency_key) { 'test_simulation_123' } + + before do + allow(client.simulate) + .to receive(:receive_transfer) + .with( + account_number_id: account_number.id, + amount: 10000, + currency: 'MXN', + idempotency_key: + ) + .and_return(expected_transfer) + end + + it 'passes idempotency key to simulate method' do + result = account_number.simulate_receive_transfer(amount: 10000, idempotency_key:) + + expect(client.simulate) + .to have_received(:receive_transfer) + .with( + account_number_id: account_number.id, + amount: 10000, + currency: 'MXN', + idempotency_key: + ) + expect(result).to eq(expected_transfer) + end + end end context 'when not in test mode' do diff --git a/spec/lib/fintoc/v2/managers/account_numbers_manager_spec.rb b/spec/lib/fintoc/v2/managers/account_numbers_manager_spec.rb index 9a83494..4c83635 100644 --- a/spec/lib/fintoc/v2/managers/account_numbers_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/account_numbers_manager_spec.rb @@ -44,8 +44,8 @@ before do allow(client).to receive(:get).with(version: :v2).and_return(get_proc) - allow(client).to receive(:post).with(version: :v2).and_return(post_proc) - allow(client).to receive(:patch).with(version: :v2).and_return(patch_proc) + allow(client).to receive(:post).with(version: :v2, idempotency_key: nil).and_return(post_proc) + allow(client).to receive(:patch).with(version: :v2, idempotency_key: nil).and_return(patch_proc) allow(get_proc) .to receive(:call) @@ -73,6 +73,27 @@ expect(Fintoc::V2::AccountNumber) .to have_received(:new).with(**first_account_number_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:post).with(version: :v2, idempotency_key:).and_return(post_proc) + end + + it 'passes idempotency_key to the POST method' do + manager.create( + account_id: 'acc_123', + description: 'My account number', + metadata: {}, + idempotency_key: + ) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + expect(Fintoc::V2::AccountNumber) + .to have_received(:new).with(**first_account_number_data, client:) + end + end end describe '#get' do @@ -99,5 +120,19 @@ expect(Fintoc::V2::AccountNumber) .to have_received(:new).with(**updated_account_number_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:patch).with(version: :v2, idempotency_key:).and_return(patch_proc) + end + + it 'passes idempotency_key to the PATCH method' do + manager.update('acno_123', description: 'Updated description', idempotency_key:) + + expect(client).to have_received(:patch).with(version: :v2, idempotency_key:) + end + end end end diff --git a/spec/support/shared_examples/clients/account_numbers_client_examples.rb b/spec/support/shared_examples/clients/account_numbers_client_examples.rb index 8a96a9e..2081d06 100644 --- a/spec/support/shared_examples/clients/account_numbers_client_examples.rb +++ b/spec/support/shared_examples/clients/account_numbers_client_examples.rb @@ -27,6 +27,25 @@ object: 'account_number' ) end + + context 'with idempotency key' do + let(:description) { 'Test account number with idempotency' } + let(:idempotency_key) { 'test_account_number_creation_123' } + + it 'returns an AccountNumber instance', :vcr do + account_number = client.account_numbers.create( + account_id:, description:, metadata: { test_id: '12345' }, idempotency_key: + ) + + expect(account_number) + .to be_an_instance_of(Fintoc::V2::AccountNumber) + .and have_attributes( + account_id:, + description:, + object: 'account_number' + ) + end + end end describe '#get' do @@ -53,8 +72,9 @@ end describe '#update' do + let(:updated_description) { 'Updated account number description' } + it 'returns an updated AccountNumber instance', :vcr do - updated_description = 'Updated account number description' account_number = client.account_numbers.update( account_number_id, description: updated_description ) @@ -66,6 +86,23 @@ description: updated_description ) end + + context 'with idempotency key' do + let(:idempotency_key) { 'test_account_number_update_456' } + + it 'returns an updated AccountNumber instance', :vcr do + account_number = client.account_numbers.update( + account_number_id, description: updated_description, idempotency_key: + ) + + expect(account_number) + .to be_an_instance_of(Fintoc::V2::AccountNumber) + .and have_attributes( + id: account_number_id, + description: updated_description + ) + end + end end end end diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_create/with_idempotency_key/returns_an_AccountNumber_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_create/with_idempotency_key/returns_an_AccountNumber_instance.yml new file mode 100644 index 0000000..8fc1573 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_create/with_idempotency_key/returns_an_AccountNumber_instance.yml @@ -0,0 +1,79 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/account_numbers + body: + encoding: UTF-8 + string: '{"account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","description":"Test + account number with idempotency","metadata":{"test_id":"12345"}}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Idempotency-Key: + - test_account_number_creation_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 15 Sep 2025 14:15:51 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '349' + Connection: + - close + Cf-Ray: + - 97f8bd111e389d5e-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/"db09652a2347900c9e96ea576c983a26" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - aea8245b-9766-414d-8dd4-d7d842eec09f + X-Runtime: + - '0.367326' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=HjM6CcsOKxOmHdgxwcmucl4Hm4s8RocNGrhc%2Fwzp6RSAVpZa8LmYPJ80vuevgz7OXhqyQg8xFEbALEoV%2FAZUq1Oiy9hoyevJCdrL4XZ2xr%2BzSAIdLYhufTbRAvWxl9tp"}],"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=32046&min_rtt=31823&rtt_var=12093&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3879&recv_bytes=2050&delivery_rate=133865&cwnd=252&unsent_bytes=0&cid=572c0657e0822b97&ts=698&x=0" + body: + encoding: UTF-8 + string: '{"id":"acno_32jtLIBHODOQzsrA0myPDSoTGaL","account_id":"acc_31yYL7h9LVPg121AgFtCyJPDsgM","number":"735969000000204474","created_at":"2025-09-15T14:15:51Z","updated_at":"2025-09-15T14:15:51Z","mode":"test","description":"Test + account number with idempotency","metadata":{"test_id":"12345"},"status":"enabled","is_root":false,"object":"account_number"}' + recorded_at: Mon, 15 Sep 2025 14:15:51 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_update/with_idempotency_key/returns_an_updated_AccountNumber_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_update/with_idempotency_key/returns_an_updated_AccountNumber_instance.yml new file mode 100644 index 0000000..c85e0c1 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_numbers_manager/_account_numbers/_update/with_idempotency_key/returns_an_updated_AccountNumber_instance.yml @@ -0,0 +1,78 @@ +--- +http_interactions: +- request: + method: patch + uri: https://api.fintoc.com/v2/account_numbers/acno_326dzRGqxLee3j9TkaBBBMfs2i0 + body: + encoding: UTF-8 + string: '{"description":"Updated account number description"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Idempotency-Key: + - test_account_number_update_456 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 15 Sep 2025 14:15:52 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '347' + Connection: + - close + Cf-Ray: + - 97f8bd169ac1c19e-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/"82628675d4c8cf1521db24ac25986c9e" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - 162b326f-fd3c-439d-a91a-d148fcc4009c + X-Runtime: + - '0.225579' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=hgHFk3QzLnXbDCYeQsx6pE5oqxiL0iNbqmwASetaVCUSHAcrR6IiazJyUkvlDbnuGA%2FwNsNzMW4OC0Rv9de68KYtZ%2B%2BM5InoArlUk4TVaKK9ez%2FGOTYvf%2BFgjwmic0aH"}],"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=33364&min_rtt=33060&rtt_var=12615&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3880&recv_bytes=2001&delivery_rate=128856&cwnd=250&unsent_bytes=0&cid=451d035434782ecf&ts=502&x=0" + body: + encoding: UTF-8 + string: '{"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"}' + recorded_at: Mon, 15 Sep 2025 14:15:52 GMT +recorded_with: VCR 6.3.1 From 67830a371c136cb0ad5430719ed3350f54e1ed79 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 11:27:09 -0300 Subject: [PATCH 6/9] feature(account-verifications): Add idempotency key to account verification methods --- .../managers/account_verifications_manager.rb | 9 ++- .../account_verifications_manager_spec.rb | 22 +++++- .../account_verifications_client_examples.rb | 17 ++++ ...eturns_an_AccountVerification_instance.yml | 79 +++++++++++++++++++ 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_verifications_manager/_account_verifications/_create/with_idempotency_key/returns_an_AccountVerification_instance.yml diff --git a/lib/fintoc/v2/managers/account_verifications_manager.rb b/lib/fintoc/v2/managers/account_verifications_manager.rb index ffd8e15..95887d9 100644 --- a/lib/fintoc/v2/managers/account_verifications_manager.rb +++ b/lib/fintoc/v2/managers/account_verifications_manager.rb @@ -8,8 +8,8 @@ def initialize(client) @client = client end - def create(account_number:) - data = _create_account_verification(account_number:) + def create(account_number:, idempotency_key: nil) + data = _create_account_verification(account_number:, idempotency_key:) build_account_verification(data) end @@ -24,8 +24,9 @@ def list(**params) private - def _create_account_verification(account_number:) - @client.post(version: :v2, use_jws: true).call('account_verifications', account_number:) + def _create_account_verification(account_number:, idempotency_key: nil) + @client.post(version: :v2, use_jws: true, idempotency_key:) + .call('account_verifications', account_number:) end def _get_account_verification(account_verification_id) diff --git a/spec/lib/fintoc/v2/managers/account_verifications_manager_spec.rb b/spec/lib/fintoc/v2/managers/account_verifications_manager_spec.rb index 5c524f4..2ce05dd 100644 --- a/spec/lib/fintoc/v2/managers/account_verifications_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/account_verifications_manager_spec.rb @@ -26,7 +26,10 @@ before do allow(client).to receive(:get).with(version: :v2).and_return(get_proc) - allow(client).to receive(:post).with(version: :v2, use_jws: true).and_return(post_proc) + allow(client) + .to receive(:post) + .with(version: :v2, use_jws: true, idempotency_key: nil) + .and_return(post_proc) allow(get_proc) .to receive(:call) @@ -50,6 +53,23 @@ expect(Fintoc::V2::AccountVerification) .to have_received(:new).with(**first_account_verification_data, client:) end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client) + .to receive(:post) + .with(version: :v2, use_jws: true, idempotency_key:) + .and_return(post_proc) + end + + it 'passes idempotency_key to the POST method' do + manager.create(account_number: '735969000000203226', idempotency_key:) + + expect(client).to have_received(:post).with(version: :v2, use_jws: true, idempotency_key:) + end + end end describe '#get' do diff --git a/spec/support/shared_examples/clients/account_verifications_client_examples.rb b/spec/support/shared_examples/clients/account_verifications_client_examples.rb index 9dd7082..9704c62 100644 --- a/spec/support/shared_examples/clients/account_verifications_client_examples.rb +++ b/spec/support/shared_examples/clients/account_verifications_client_examples.rb @@ -57,6 +57,23 @@ status: 'pending' ) end + + context 'with idempotency key' do + let(:idempotency_key) { 'test_account_verification_123' } + + it 'returns an AccountVerification instance', :vcr do + account_verification = client.account_verifications.create( + account_number:, idempotency_key: + ) + + expect(account_verification) + .to be_an_instance_of(Fintoc::V2::AccountVerification) + .and have_attributes( + object: 'account_verification', + status: 'pending' + ) + end + end end describe '#get' do diff --git a/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_verifications_manager/_account_verifications/_create/with_idempotency_key/returns_an_AccountVerification_instance.yml b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_verifications_manager/_account_verifications/_create/with_idempotency_key/returns_an_AccountVerification_instance.yml new file mode 100644 index 0000000..7b8c6d7 --- /dev/null +++ b/spec/vcr/Fintoc_V2_Client/behaves_like_a_client_with_account_verifications_manager/_account_verifications/_create/with_idempotency_key/returns_an_AccountVerification_instance.yml @@ -0,0 +1,79 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.fintoc.com/v2/account_verifications + body: + encoding: UTF-8 + string: '{"account_number":"735969000000203226"}' + headers: + Authorization: + - Bearer sk_test_SeCreT-aPi_KeY + User-Agent: + - fintoc-ruby/1.0.0 + Fintoc-Jws-Signature: + - eyJhbGciOiJSUzI1NiIsIm5vbmNlIjoiMWZlZTdlZTM5ODNjYTM3ZDUzNDJjYmY0NzkzNGQ5MzAiLCJ0cyI6MTc1Nzk0NjM4NiwiY3JpdCI6WyJ0cyIsIm5vbmNlIl19.bHt7OsP29pjU_w7jBXEpxbOhDIJdXY6ofO4W9s6qhAFKpYVjMbIFBSIivBfhHgQvs3_9eXbqbo6Z0eLTwQve-3k714d_7LH7M_DryV5TbdfzqiKDi6ukIehFXiudRvS82wy7IaUyFaMORmivy4l9O2zMMG66Hd5XhdXSqd2VjaHlM2KaQL9edj-s309cd7nc2Z6kEcGenlIKqOXF3z1d6wwU2PIegeDpIeckeg-wTkJ5_mlB-VMTzPOF93F1XTvtZBuHUPwHOuPxxU6LnKzdUgvYPV9nlZvnmcnxI0B26N9-0EvgzB0-O7Bv1Y2osfjZPBGLrmEtEELdLgK2PKpu5Q + Idempotency-Key: + - test_account_verification_123 + Connection: + - close + Content-Type: + - application/json; charset=utf-8 + Host: + - api.fintoc.com + response: + status: + code: 201 + message: Created + headers: + Date: + - Mon, 15 Sep 2025 14:26:28 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '402' + Connection: + - close + Cf-Ray: + - 97f8cc94a8c21ea3-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/"6631e33b56566f095f3f0da1b8c53407" + Cache-Control: + - max-age=0, private, must-revalidate + X-Request-Id: + - df427824-b17e-43bb-bf4d-dcffb18baf29 + X-Runtime: + - '1.337826' + Vary: + - Origin + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Ei2sKeQmmMBBt%2Bj62xY1UQovAH857e3V%2B816eWwPFJG3TNXPIxT7nDB151mhU4CICmfYz0tieX2LRaIYEuCoOW671KTSWcTEXPChVvs3DcrhrFl7I5ySHsIl4Ubmlbc9"}],"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=30912&min_rtt=30292&rtt_var=11802&sent=7&recv=8&lost=0&retrans=0&sent_bytes=3878&recv_bytes=2454&delivery_rate=140631&cwnd=251&unsent_bytes=0&cid=d9d333268f536798&ts=1611&x=0" + body: + encoding: UTF-8 + string: '{"object":"account_verification","id":"accv_32judMrUZx0kkUwpMOJTEYS7kbk","status":"pending","reason":null,"transfer_id":"tr_32judA57tpgGHcgHegasIjx86Nu","counterparty":{"holder_id":null,"holder_name":null,"account_number":"735969000000203226","account_type":"clabe","institution":{"id":"90735","name":"FINTOC","country":"mx"}},"mode":"test","receipt_url":null,"transaction_date":"2025-09-15T14:26:26Z"}' + recorded_at: Mon, 15 Sep 2025 14:26:28 GMT +recorded_with: VCR 6.3.1 From bfafd502e433711996a1fab783f5e4175df5d0f2 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 11:29:12 -0300 Subject: [PATCH 7/9] feature(docs): Add idempotency examples/explanations to the docs --- README.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/README.md b/README.md index cbd96c0..8c214e8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ Do yourself a favor: go grab some ice cubes by installing this refreshing librar - [Transfers](#transfers) - [Simulate](#simulate) - [Account Verifications](#account-verifications) + - [Idempotency Keys](#idempotency-keys) + - [Idempotency Examples](#idempotency-examples) + - [Account Methods with Idempotency Key](#account-methods-with-idempotency-key) + - [Account Number Methods with Idempotency Key](#account-number-methods-with-idempotency-key) + - [Transfer Methods with Idempotency Key](#transfer-methods-with-idempotency-key) + - [Simulation with Idempotency Key](#simulation-with-idempotency-key) + - [Account Verification with Idempotency Key](#account-verification-with-idempotency-key) + - [About idempotency keys](#about-idempotency-keys) - [Development](#development) - [Dependencies](#dependencies) - [Setup](#setup) @@ -378,6 +386,116 @@ account_verification = client.v2.account_verifications.get('account_verification account_verifications = client.v2.account_verifications.list ``` +## Idempotency Keys + +The Fintoc API supports [idempotency](https://docs.fintoc.com/reference/idempotent-requests) for safely retrying requests without accidentally performing the same operation twice. This is particularly useful when creating transfers, account numbers, accounts, or other resources where you want to avoid duplicates due to network issues. + +To use idempotency keys, provide an `idempotency_key` parameter when making POST/PATCH requests: + +### Idempotency Examples + +#### Account Methods with Idempotency Key + +Create and update methods support the use of idempotency keys to prevent duplication: + +```ruby +require 'fintoc' +require 'securerandom' + +client = Fintoc::Client.new('api_key', jws_private_key: 'jws_private_key') + +idempotency_key = SecureRandom.uuid +account = client.v2.accounts.create( + entity_id: 'entity_id', description: 'My Business Account', idempotency_key: +) + +idempotency_key = SecureRandom.uuid +updated_account = client.v2.accounts.update( + 'account_id', description: 'Updated Description', idempotency_key: +) +``` + +Simulation of transfers can also be done with idempotency key: + +```ruby +idempotency_key = SecureRandom.uuid +account.simulate_receive_transfer(amount: 1000, idempotency_key:) +``` + +#### Account Number Methods with Idempotency Key + +Create and update methods support the use of idempotency keys as well: + +```ruby +idempotency_key = SecureRandom.uuid +account_number = client.v2.account_numbers.create( + account_id: 'account_id', description: 'Main account number', idempotency_key: +) + +idempotency_key = SecureRandom.uuid +updated_account_number = client.v2.account_numbers.update( + 'account_number_id', description: 'Updated description', idempotency_key: +) +``` + +Simulation of transfers can also be done with idempotency key: + +```ruby +account_number.simulate_receive_transfer(amount: 1000, currency: 'MXN', idempotency_key:) +``` + +#### Transfer Methods with Idempotency Key + +Creating and returning transfers support the use of idempotency keys: + +```ruby +idempotency_key = SecureRandom.uuid +transfer = client.v2.transfers.create( + amount: 10000, currency: 'CLP', account_id: 'account_id', counterparty: { ... }, idempotency_key: +) + +idempotency_key = SecureRandom.uuid +returned_transfer = client.v2.transfers.return('transfer_id', idempotency_key:) +``` + +Returning a transfer as an instance method also supports the use of idempotency key: + +```ruby +idempotency_key = SecureRandom.uuid +transfer.return_transfer(idempotency_key:) +``` + +#### Simulation with Idempotency Key + +For simulating transfers, the use of idempotency keys is also supported: + +```ruby +idempotency_key = SecureRandom.uuid +simulated_transfer = client.v2.simulate.receive_transfer( + account_number_id: 'account_number_id', amount: 5000, currency: 'CLP', idempotency_key: +) +``` + +#### Account Verification with Idempotency Key + +```ruby +idempotency_key = SecureRandom.uuid +account_verification = client.v2.account_verifications.create( + account_number: 'account_number', idempotency_key: +) +``` + +### About idempotency keys + +- Idempotency keys can be up to 255 characters long +- Use consistent unique identifiers for the same logical operation (e.g. order IDs, transaction references). If you set them randomly, we suggest using V4 UUIDs, or another random string with enough entropy to avoid collisions. +- The same idempotency key will return the same result, including errors +- Keys are automatically removed after 24 hours +- Only POST and PATCH requests currently support idempotency keys +- If parameters differ with the same key, an error will be raised + +For more information, see the [Fintoc API documentation on idempotent requests](https://docs.fintoc.com/reference/idempotent-requests). + ## Development ### Dependencies From 06f96f8da6d4a0792c3dd8e224a9390cad4d6465 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 11:29:51 -0300 Subject: [PATCH 8/9] refactor(docs): Prefer Accounts over Transfer Accounts (the second term is used in the code for Movements accounts) --- CHANGELOG.md | 2 +- README.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f7f10..f4aa179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - **V2 Client - Transfers API Implementation**: Partial implementation of Transfers API endpoints in `Fintoc::V2::Client` - **Entities**: List and retrieve business entities - - **Transfer Accounts**: Create, read, update, and list transfer accounts + - **Accounts**: Create, read, update, and list accounts - **Account Numbers**: Manage account numbers/CLABEs - **Transfers**: Create, retrieve, list, and return transfers - **Simulation**: Simulate receiving transfers for testing diff --git a/README.md b/README.md index 8c214e8..700aabc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Do yourself a favor: go grab some ice cubes by installing this refreshing librar - [Get movements](#get-movements) - [Transfers API Examples](#transfers-api-examples) - [Entities](#entities) - - [Transfer Accounts](#transfer-accounts) + - [Accounts](#accounts) - [Account Numbers](#account-numbers) - [Transfers](#transfers) - [Simulate](#simulate) @@ -115,7 +115,7 @@ client = Fintoc::Client.new('api_key', jws_private_key: 'jws_private_key') entities = client.v2.entities.list entity = client.v2.entities.get('entity_id') -# Transfer Accounts +# Accounts accounts = client.v2.accounts.list account = client.v2.accounts.get('account_id') account = client.v2.accounts.create(entity_id: 'entity_id', description: 'My Account') @@ -273,14 +273,14 @@ You can also list entities with pagination: entities = client.v2.entities.list(limit: 10, starting_after: 'entity_id') ``` -#### Transfer Accounts +#### Accounts ```ruby require 'fintoc' client = Fintoc::Client.new('api_key', jws_private_key: 'jws_private_key') -# Create a transfer account +# Create an account account = client.v2.accounts.create( entity_id: 'entity_id', description: 'My Business Account' From 0d529732aac3a87ba6ed8c294e773d86ab6a2c79 Mon Sep 17 00:00:00 2001 From: Pedro Bahamondes Date: Mon, 15 Sep 2025 11:42:33 -0300 Subject: [PATCH 9/9] feature(version): Bump version to 1.1.0 with changelog --- CHANGELOG.md | 14 +++++++++++++- Gemfile.lock | 2 +- lib/fintoc/version.rb | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4aa179..d1a33e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.1.0 - 2025-09-15 + +### 🚀 New Features + +- **Idempotency Key Support**: Added comprehensive idempotency key support across all V2 API operations to help prevent duplicate operations during network issues or retries + - **Accounts**: `create` and `update` methods now accept the `idempotency_key` parameter + - **Account Numbers**: `create` and `update` methods support idempotency keys + - **Transfers**: `create` and `return` operations support idempotency keys + - **Account Verifications**: `create` method supports idempotency keys + - **Simulation**: `receive_transfer` method supports idempotency keys + - **Resource-level methods**: Instance methods like `account.update`, `transfer.return_transfer`, and `account.simulate_receive_transfer` all support idempotency keys + ## 1.0.0 - 2025-09-05 ### 🚀 New Features @@ -42,4 +54,4 @@ Initial version -* Up to date with the [2020-11-17](https://docs.fintoc.com/docs/api-changelog#2020-11-17) API version +- Up to date with the [2020-11-17](https://docs.fintoc.com/docs/api-changelog#2020-11-17) API version diff --git a/Gemfile.lock b/Gemfile.lock index e0d60c6..135033e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fintoc (1.0.0) + fintoc (1.1.0) http money-rails tabulate diff --git a/lib/fintoc/version.rb b/lib/fintoc/version.rb index 36e3c44..110d24a 100644 --- a/lib/fintoc/version.rb +++ b/lib/fintoc/version.rb @@ -1,3 +1,3 @@ module Fintoc - VERSION = '1.0.0' + VERSION = '1.1.0' end