From fda33e5d3b191d54063e39a4f7726a0166ff3565 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:43:21 -0400 Subject: [PATCH 1/6] feat(client): support multipart/form-data uploads Add a PUT verb and multipart form support to the base client so file uploads can be sent via the http gem; file values (IO-like objects or file paths) are wrapped as HTTP::FormData::File, while other form fields are sent as-is. JSON requests are unaffected. --- lib/fintoc/base_client.rb | 20 +++++++++++++++++++- spec/lib/fintoc/base_client_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/fintoc/base_client.rb b/lib/fintoc/base_client.rb index 3dc64ed..63e8a20 100644 --- a/lib/fintoc/base_client.rb +++ b/lib/fintoc/base_client.rb @@ -38,6 +38,10 @@ def patch(version: :v1, use_jws: false, idempotency_key: nil) request('patch', version:, use_jws:, idempotency_key:) end + def put(version: :v1, use_jws: false, idempotency_key: nil) + request('put', version:, use_jws:, idempotency_key:) + end + def request(method, version: :v1, use_jws: false, idempotency_key: nil) proc do |resource, **kwargs| parameters = params(method, **kwargs) @@ -122,13 +126,27 @@ def make_request( end def params(method, **kwargs) - if method == 'get' + if kwargs.key?(:form) + { form: build_form(kwargs[:form]) } + elsif method == 'get' { params: { **@default_params, **kwargs } } else { json: { **@default_params, **kwargs } } end end + # Wraps file values (IO-like objects or file paths) as multipart file parts + # and leaves the rest as plain fields, so a form can mix files and fields. + def build_form(form) + form.transform_values do |value| + file?(value) ? HTTP::FormData::File.new(value) : value + end + end + + def file?(value) + value.respond_to?(:read) || (value.is_a?(String) && File.file?(value)) + end + def raise_custom_error(error) raise error_class(error[:code]).new(error[:message], error[:doc_url]) end diff --git a/spec/lib/fintoc/base_client_spec.rb b/spec/lib/fintoc/base_client_spec.rb index 90901a9..8f37ad5 100644 --- a/spec/lib/fintoc/base_client_spec.rb +++ b/spec/lib/fintoc/base_client_spec.rb @@ -34,6 +34,13 @@ end end + describe '#put' do + it 'returns a proc for PUT requests' do + put_request = client.put(version: :v1) + expect(put_request).to be_a(Proc) + end + end + describe '#post' do it 'returns a proc for POST requests' do post_request = client.post(version: :v1) @@ -317,6 +324,25 @@ end end + describe '#params' do + it 'wraps file form values and leaves other fields untouched' do + result = client.send(:params, 'put', + form: { file: 'spec/support/fixtures/sample_document.pdf', + description: 'contract' }) + + expect(result[:form][:file]).to be_a(HTTP::FormData::File) + expect(result[:form][:description]).to eq('contract') + end + + it 'builds a JSON body for non-GET requests' do + expect(client.send(:params, 'post', amount: 100)).to eq(json: { amount: 100 }) + end + + it 'builds query params for GET requests' do + expect(client.send(:params, 'get', page: 2)).to eq(params: { page: 2 }) + end + end + describe '#to_s' do it 'returns masked API key representation' do result = client.to_s From 8dcc7c93b51719a902920cef75750488a0e57329 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:43:21 -0400 Subject: [PATCH 2/6] feat(v2): add onboardings under entities Onboarding resource + manager nested off the Entity instance (entity.onboardings), mirroring account_statements: list, get, create, submit, and slot + shareholder document uploads. Entity also exposes status and country_code. --- lib/fintoc/v2/managers/onboardings_manager.rb | 86 +++++++ lib/fintoc/v2/resources/entity.rb | 14 +- lib/fintoc/v2/resources/onboarding.rb | 41 ++++ spec/lib/fintoc/v2/entity_spec.rb | 30 +++ .../v2/managers/onboardings_manager_spec.rb | 220 ++++++++++++++++++ spec/lib/fintoc/v2/onboarding_spec.rb | 112 +++++++++ spec/support/fixtures/sample_document.pdf | Bin 0 -> 14 bytes 7 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 lib/fintoc/v2/managers/onboardings_manager.rb create mode 100644 lib/fintoc/v2/resources/onboarding.rb create mode 100644 spec/lib/fintoc/v2/managers/onboardings_manager_spec.rb create mode 100644 spec/lib/fintoc/v2/onboarding_spec.rb create mode 100644 spec/support/fixtures/sample_document.pdf diff --git a/lib/fintoc/v2/managers/onboardings_manager.rb b/lib/fintoc/v2/managers/onboardings_manager.rb new file mode 100644 index 0000000..d93f1d0 --- /dev/null +++ b/lib/fintoc/v2/managers/onboardings_manager.rb @@ -0,0 +1,86 @@ +require 'fintoc/v2/resources/onboarding' + +module Fintoc + module V2 + module Managers + class OnboardingsManager + def initialize(client, entity_id) + @client = client + @entity_id = entity_id + end + + def list(**params) + _list_onboardings(**params).map { |data| build_onboarding(data) } + end + + def get(onboarding_id) + data = _get_onboarding(onboarding_id) + build_onboarding(data) + end + + def create(idempotency_key: nil, **params) + data = _create_onboarding(idempotency_key:, **params) + build_onboarding(data) + end + + def submit(onboarding_id, idempotency_key: nil) + data = _submit_onboarding(onboarding_id, idempotency_key:) + build_onboarding(data) + end + + def upload_document(onboarding_id, slot_key, file:, idempotency_key: nil) + data = _upload_document(onboarding_id, slot_key, file:, idempotency_key:) + build_onboarding(data) + end + + def upload_shareholder_document(onboarding_id, shareholder_id, file:, idempotency_key: nil) + data = _upload_shareholder_document( + onboarding_id, shareholder_id, file:, idempotency_key: + ) + build_onboarding(data) + end + + private + + def base_path + "entities/#{@entity_id}/onboardings" + end + + def _list_onboardings(**params) + @client.get(version: :v2).call(base_path, **params) + end + + def _get_onboarding(onboarding_id) + @client.get(version: :v2).call("#{base_path}/#{onboarding_id}") + end + + def _create_onboarding(idempotency_key: nil, **params) + @client.post(version: :v2, idempotency_key:).call(base_path, **params) + end + + def _submit_onboarding(onboarding_id, idempotency_key: nil) + @client.post(version: :v2, idempotency_key:) + .call("#{base_path}/#{onboarding_id}/submit") + end + + def _upload_document(onboarding_id, slot_key, file:, idempotency_key: nil) + @client.put(version: :v2, idempotency_key:).call( + "#{base_path}/#{onboarding_id}/documents/#{slot_key}", + form: { file: } + ) + end + + def _upload_shareholder_document(onboarding_id, shareholder_id, file:, idempotency_key: nil) + @client.put(version: :v2, idempotency_key:).call( + "#{base_path}/#{onboarding_id}/shareholders/#{shareholder_id}/document", + form: { file: } + ) + end + + def build_onboarding(data) + Fintoc::V2::Onboarding.new(**data, client: @client) + end + end + end + end +end diff --git a/lib/fintoc/v2/resources/entity.rb b/lib/fintoc/v2/resources/entity.rb index 29cc562..045776d 100644 --- a/lib/fintoc/v2/resources/entity.rb +++ b/lib/fintoc/v2/resources/entity.rb @@ -1,7 +1,9 @@ +require 'fintoc/v2/managers/onboardings_manager' + module Fintoc module V2 class Entity - attr_reader :object, :mode, :id, :holder_name, :holder_id, :is_root + attr_reader :object, :mode, :id, :holder_name, :holder_id, :is_root, :status, :country_code def initialize( object:, @@ -10,6 +12,8 @@ def initialize( holder_name:, holder_id:, is_root:, + status: nil, + country_code: nil, client: nil, ** ) @@ -19,6 +23,8 @@ def initialize( @holder_name = holder_name @holder_id = holder_id @is_root = is_root + @status = status + @country_code = country_code @client = client end @@ -31,6 +37,10 @@ def refresh refresh_from_entity(fresh_entity) end + def onboardings + @onboardings ||= Managers::OnboardingsManager.new(@client, @id) + end + private def refresh_from_entity(entity) @@ -43,6 +53,8 @@ def refresh_from_entity(entity) @holder_name = entity.holder_name @holder_id = entity.holder_id @is_root = entity.is_root + @status = entity.status + @country_code = entity.country_code self end diff --git a/lib/fintoc/v2/resources/onboarding.rb b/lib/fintoc/v2/resources/onboarding.rb new file mode 100644 index 0000000..40cd483 --- /dev/null +++ b/lib/fintoc/v2/resources/onboarding.rb @@ -0,0 +1,41 @@ +module Fintoc + module V2 + class Onboarding + attr_reader :object, :id, :entity_id, :status, :source, :submitted_at, :reviewed_at, + :submittable, :data, :shareholders, :documents + + def initialize( + object:, + id:, + entity_id: nil, + status: nil, + source: nil, + submitted_at: nil, + reviewed_at: nil, + submittable: nil, + data: nil, + shareholders: nil, + documents: nil, + client: nil, + ** + ) + @object = object + @id = id + @entity_id = entity_id + @status = status + @source = source + @submitted_at = submitted_at + @reviewed_at = reviewed_at + @submittable = submittable + @data = data + @shareholders = shareholders + @documents = documents + @client = client + end + + def to_s + "📋 Onboarding #{@id} (#{@status})" + end + end + end +end diff --git a/spec/lib/fintoc/v2/entity_spec.rb b/spec/lib/fintoc/v2/entity_spec.rb index 067d3de..44a60e7 100644 --- a/spec/lib/fintoc/v2/entity_spec.rb +++ b/spec/lib/fintoc/v2/entity_spec.rb @@ -12,6 +12,8 @@ holder_name: 'Test Company LLC', holder_id: '12345678-9', is_root: true, + status: 'active', + country_code: 'CL', client: client } end @@ -25,6 +27,19 @@ it 'sets all attributes correctly' do expect(entity).to have_attributes( + object: 'entity', + mode: 'test', + id: 'ent_12345', + holder_name: 'Test Company LLC', + holder_id: '12345678-9', + is_root: true, + status: 'active', + country_code: 'CL' + ) + end + + it 'defaults status and country_code to nil when not provided' do + minimal = described_class.new( object: 'entity', mode: 'test', id: 'ent_12345', @@ -32,6 +47,21 @@ holder_id: '12345678-9', is_root: true ) + + expect(minimal).to have_attributes(status: nil, country_code: nil) + end + end + + describe '#onboardings' do + it 'returns an OnboardingsManager instance' do + expect(entity.onboardings) + .to be_an_instance_of(Fintoc::V2::Managers::OnboardingsManager) + end + + it 'memoizes the manager instance' do + first_call = entity.onboardings + second_call = entity.onboardings + expect(first_call).to be(second_call) end end diff --git a/spec/lib/fintoc/v2/managers/onboardings_manager_spec.rb b/spec/lib/fintoc/v2/managers/onboardings_manager_spec.rb new file mode 100644 index 0000000..3b7439f --- /dev/null +++ b/spec/lib/fintoc/v2/managers/onboardings_manager_spec.rb @@ -0,0 +1,220 @@ +require 'fintoc/v2/managers/onboardings_manager' + +RSpec.describe Fintoc::V2::Managers::OnboardingsManager do + let(:client) { instance_double(Fintoc::BaseClient) } + let(:get_proc) { instance_double(Proc) } + let(:post_proc) { instance_double(Proc) } + let(:put_proc) { instance_double(Proc) } + let(:entity_id) { 'ent_12345' } + let(:onboarding_id) { 'onbprc_0ujs' } + let(:shareholder_id) { 'onbsh_123' } + let(:base_path) { "entities/#{entity_id}/onboardings" } + let(:manager) { described_class.new(client, entity_id) } + + let(:first_onboarding_data) do + { + id: onboarding_id, + object: 'onboarding', + entity_id: entity_id, + status: 'in_progress', + source: 'api', + submitted_at: nil, + reviewed_at: nil + } + end + + let(:second_onboarding_data) do + { + id: 'onbprc_other', + object: 'onboarding', + entity_id: entity_id, + status: 'submitted', + source: 'dashboard', + submitted_at: '2026-06-22T00:00:00Z', + reviewed_at: nil + } + end + + let(:full_onboarding_data) do + first_onboarding_data.merge( + submittable: true, + data: {}, + shareholders: [], + documents: [] + ) + end + + let(:create_params) do + { + company_information: { legal_name: 'ACME Inc.' }, + legal_representative: { first_name: 'Jane', last_name: 'Doe' }, + transactional_profile: { monthly_amount_range: '0-1000' }, + shareholders: [{ type: 'natural_person', name: 'Jane', percentage: 100 }] + } + end + + before do + allow(client).to receive(:get).with(version: :v2).and_return(get_proc) + allow(client).to receive(:post).with(version: :v2, idempotency_key: nil).and_return(post_proc) + allow(client).to receive(:put).with(version: :v2, idempotency_key: nil).and_return(put_proc) + + allow(get_proc) + .to receive(:call) + .with(base_path) + .and_return([first_onboarding_data, second_onboarding_data]) + allow(get_proc) + .to receive(:call) + .with("#{base_path}/#{onboarding_id}") + .and_return(full_onboarding_data) + allow(post_proc) + .to receive(:call) + .with(base_path, **create_params) + .and_return(full_onboarding_data) + allow(post_proc) + .to receive(:call) + .with("#{base_path}/#{onboarding_id}/submit") + .and_return(full_onboarding_data) + allow(put_proc).to receive(:call).and_return(full_onboarding_data) + + allow(Fintoc::V2::Onboarding).to receive(:new) + end + + describe '#list' do + it 'fetches and builds onboardings (light shape)' do + manager.list + + expect(get_proc).to have_received(:call).with(base_path) + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**first_onboarding_data, client:) + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**second_onboarding_data, client:) + end + + it 'passes parameters to the API call' do + params = { per_page: 50 } + allow(get_proc).to receive(:call).with(base_path, **params).and_return([]) + + manager.list(**params) + + expect(get_proc).to have_received(:call).with(base_path, **params) + end + end + + describe '#get' do + it 'fetches and builds a full onboarding' do + manager.get(onboarding_id) + + expect(get_proc).to have_received(:call).with("#{base_path}/#{onboarding_id}") + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**full_onboarding_data, client:) + end + end + + describe '#create' do + it 'forwards the nested structures as JSON and builds the onboarding' do + manager.create(**create_params) + + expect(post_proc).to have_received(:call).with(base_path, **create_params) + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**full_onboarding_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) + allow(post_proc).to receive(:call).with(base_path, **create_params) + .and_return(full_onboarding_data) + end + + it 'passes idempotency_key to the POST method' do + manager.create(idempotency_key:, **create_params) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + end + end + end + + describe '#submit' do + it 'posts to the submit path without a body and builds the onboarding' do + manager.submit(onboarding_id) + + expect(post_proc).to have_received(:call).with("#{base_path}/#{onboarding_id}/submit") + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**full_onboarding_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) + allow(post_proc).to receive(:call).with("#{base_path}/#{onboarding_id}/submit") + .and_return(full_onboarding_data) + end + + it 'passes idempotency_key to the POST method' do + manager.submit(onboarding_id, idempotency_key:) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + end + end + end + + describe '#upload_document' do + let(:slot_key) { 'incorporation_certificate' } + let(:file_path) { 'spec/support/fixtures/sample_document.pdf' } + + it 'puts a multipart file to the document slot path' do + manager.upload_document(onboarding_id, slot_key, file: file_path) + + expect(client).to have_received(:put).with(version: :v2, idempotency_key: nil) + expect(put_proc).to have_received(:call).with( + "#{base_path}/#{onboarding_id}/documents/#{slot_key}", + form: { file: file_path } + ) + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**full_onboarding_data, client:) + end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:put).with(version: :v2, idempotency_key:).and_return(put_proc) + end + + it 'passes idempotency_key to the PUT method' do + manager.upload_document(onboarding_id, slot_key, file: file_path, idempotency_key:) + + expect(client).to have_received(:put).with(version: :v2, idempotency_key:) + end + end + end + + describe '#upload_shareholder_document' do + let(:file_path) { 'spec/support/fixtures/sample_document.pdf' } + + it 'puts a multipart file to the shareholder document path' do + manager.upload_shareholder_document(onboarding_id, shareholder_id, file: file_path) + + expect(client).to have_received(:put).with(version: :v2, idempotency_key: nil) + expect(put_proc).to have_received(:call).with( + "#{base_path}/#{onboarding_id}/shareholders/#{shareholder_id}/document", + form: { file: file_path } + ) + expect(Fintoc::V2::Onboarding).to have_received(:new).with(**full_onboarding_data, client:) + end + + context 'when idempotency_key is provided' do + let(:idempotency_key) { '123e4567-e89b-12d3-a456-426614174000' } + + before do + allow(client).to receive(:put).with(version: :v2, idempotency_key:).and_return(put_proc) + end + + it 'passes idempotency_key to the PUT method' do + manager.upload_shareholder_document( + onboarding_id, shareholder_id, file: file_path, idempotency_key: + ) + + expect(client).to have_received(:put).with(version: :v2, idempotency_key:) + end + end + end +end diff --git a/spec/lib/fintoc/v2/onboarding_spec.rb b/spec/lib/fintoc/v2/onboarding_spec.rb new file mode 100644 index 0000000..0899018 --- /dev/null +++ b/spec/lib/fintoc/v2/onboarding_spec.rb @@ -0,0 +1,112 @@ +require 'fintoc/v2/resources/onboarding' + +RSpec.describe Fintoc::V2::Onboarding do + let(:api_key) { 'sk_test_SeCreT-aPi_KeY' } + let(:client) { Fintoc::V2::Client.new(api_key) } + + let(:shareholder_data) do + { + id: 'onbsh_123', + object: 'onboarding_shareholder', + type: 'natural_person', + name: 'Jane', + last_name: 'Doe', + percentage: 60, + holder_id: '12345678-9', + parent_id: nil, + document: { slot_key: 'shareholder_id', status: 'missing' } + } + end + + let(:document_data) do + { + slot_key: 'incorporation_certificate', + status: 'uploaded', + filename: 'cert.pdf', + uploaded_at: '2026-06-22T00:00:00Z' + } + end + + let(:data) do + { + id: 'onbprc_0ujs', + object: 'onboarding', + entity_id: 'ent_12345', + status: 'in_progress', + source: 'api', + submitted_at: nil, + reviewed_at: nil, + submittable: false, + data: { company_information: { legal_name: 'ACME Inc.' } }, + shareholders: [shareholder_data], + documents: [document_data], + client: client + } + end + + let(:onboarding) { described_class.new(**data) } + + describe '#new' do + it 'creates an instance of Onboarding' do + expect(onboarding).to be_an_instance_of(described_class) + end + + it 'sets all attributes correctly' do # rubocop:disable RSpec/ExampleLength + expect(onboarding).to have_attributes( + id: 'onbprc_0ujs', + object: 'onboarding', + entity_id: 'ent_12345', + status: 'in_progress', + source: 'api', + submitted_at: nil, + reviewed_at: nil, + submittable: false, + data: { company_information: { legal_name: 'ACME Inc.' } }, + shareholders: [shareholder_data], + documents: [document_data] + ) + end + + it 'keeps shareholders as raw hashes' do + expect(onboarding.shareholders.first).to eq(shareholder_data) + end + + it 'keeps documents as raw hashes' do + expect(onboarding.documents.first).to eq(document_data) + end + end + + describe 'light shape' do + let(:light_data) do + { + id: 'onbprc_0ujs', + object: 'onboarding', + entity_id: 'ent_12345', + status: 'pending', + source: 'dashboard', + submitted_at: nil, + reviewed_at: nil + } + end + + let(:light_onboarding) { described_class.new(**light_data) } + + it 'builds without full attributes' do + expect(light_onboarding).to have_attributes( + id: 'onbprc_0ujs', + status: 'pending', + source: 'dashboard', + submittable: nil, + data: nil, + shareholders: nil, + documents: nil + ) + end + end + + describe '#to_s' do + it 'returns a formatted string representation' do + expect(onboarding.to_s).to eq('📋 Onboarding onbprc_0ujs (in_progress)') + end + end +end diff --git a/spec/support/fixtures/sample_document.pdf b/spec/support/fixtures/sample_document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8b93536a18b65921862ab51180cb01343afa758e GIT binary patch literal 14 VcmY!laB Date: Mon, 22 Jun 2026 16:43:21 -0400 Subject: [PATCH 3/6] feat(v2): expose entity creation entities.create requires holder_name, holder_id and country_code. --- lib/fintoc/v2/managers/entities_manager.rb | 10 +++++++ .../v2/managers/entities_manager_spec.rb | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/fintoc/v2/managers/entities_manager.rb b/lib/fintoc/v2/managers/entities_manager.rb index 4cd0071..ebea19c 100644 --- a/lib/fintoc/v2/managers/entities_manager.rb +++ b/lib/fintoc/v2/managers/entities_manager.rb @@ -8,6 +8,11 @@ def initialize(client) @client = client end + def create(holder_name:, holder_id:, country_code:, idempotency_key: nil, **params) + data = _create_entity(holder_name:, holder_id:, country_code:, idempotency_key:, **params) + build_entity(data) + end + def get(entity_id) data = _get_entity(entity_id) build_entity(data) @@ -19,6 +24,11 @@ def list(**params) private + def _create_entity(holder_name:, holder_id:, country_code:, idempotency_key: nil, **params) + @client.post(version: :v2, idempotency_key:) + .call('entities', holder_name:, holder_id:, country_code:, **params) + end + def _get_entity(entity_id) @client.get(version: :v2).call("entities/#{entity_id}") end diff --git a/spec/lib/fintoc/v2/managers/entities_manager_spec.rb b/spec/lib/fintoc/v2/managers/entities_manager_spec.rb index 8a27b47..3583b0e 100644 --- a/spec/lib/fintoc/v2/managers/entities_manager_spec.rb +++ b/spec/lib/fintoc/v2/managers/entities_manager_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Fintoc::V2::Managers::EntitiesManager do let(:client) { instance_double(Fintoc::BaseClient) } let(:get_proc) { instance_double(Proc) } + let(:post_proc) { instance_double(Proc) } let(:manager) { described_class.new(client) } let(:entity_id) { 'ent_31t0VhhrAXASFQTVYfCfIBnljbT' } @@ -31,15 +32,43 @@ let(:entities_data) { [first_entity_data, second_entity_data] } + let(:create_params) { { holder_name: 'Fintoc', holder_id: '12345678-9', country_code: 'cl' } } + before do allow(client).to receive(:get).with(version: :v2).and_return(get_proc) + allow(client).to receive(:post).with(version: :v2, idempotency_key: nil).and_return(post_proc) allow(get_proc).to receive(:call).with("entities/#{entity_id}").and_return(first_entity_data) allow(get_proc).to receive(:call).with('entities').and_return(entities_data) + allow(post_proc).to receive(:call).with('entities', + **create_params).and_return(first_entity_data) allow(Fintoc::V2::Entity).to receive(:new) end + describe '#create' do + it 'posts the entity payload and builds the entity' do + manager.create(**create_params) + + expect(post_proc).to have_received(:call).with('entities', **create_params) + expect(Fintoc::V2::Entity).to have_received(:new).with(**first_entity_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(idempotency_key:, **create_params) + + expect(client).to have_received(:post).with(version: :v2, idempotency_key:) + end + end + end + describe '#get' do it 'fetches and builds an entity' do manager.get(entity_id) From 62959609f99771c3dd2fa43cd42146d4be53c445 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 16:43:21 -0400 Subject: [PATCH 4/6] docs(v2): document entity onboardings usage --- CHANGELOG.md | 11 ++++++++ README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1218807..6fc98c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### 🚀 New Features + +- **Onboardings**: Added the full onboarding lifecycle under v2 entities. Onboardings are nested off an `Entity` instance via `entity.onboardings`: + - `list`, `get`, and `create` (the create body is passed through as-is and validated by the API) + - `submit` to send an onboarding for review + - `upload_document` and `upload_shareholder_document` for multipart document uploads +- **Multipart uploads**: Added multipart `form` support and a `put` verb to the HTTP layer to back the onboarding document uploads (uploads are not JWS-signed). +- **Entity attributes**: Added `status` and `country_code` to the v2 `Entity` resource. + ## 1.2.0 - 2026-04-08 ### 🚀 New Features diff --git a/README.md b/README.md index 700aabc..86114d0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Do yourself a favor: go grab some ice cubes by installing this refreshing librar - [Transfers](#transfers) - [Simulate](#simulate) - [Account Verifications](#account-verifications) + - [Entity onboardings API Examples](#entity-onboardings-api-examples) - [Idempotency Keys](#idempotency-keys) - [Idempotency Examples](#idempotency-examples) - [Account Methods with Idempotency Key](#account-methods-with-idempotency-key) @@ -150,6 +151,11 @@ account_verifications = client.v2.account_verifications.list account_verification = client.v2.account_verifications.get('account_verification_id') account_verification = client.v2.account_verifications.create(account_number: 'account_number') +# Entity onboardings +entity = client.v2.entities.get('entity_id') +onboardings = entity.onboardings.list +onboarding = entity.onboardings.get('onboarding_id') + # TODO: Movements ``` @@ -386,6 +392,76 @@ account_verification = client.v2.account_verifications.get('account_verification account_verifications = client.v2.account_verifications.list ``` +### Entity onboardings API Examples + +An entity onboarding represents the onboarding process for an entity. You access it from +an `Entity` instance (`entity.onboardings`); the full lifecycle is available: list, +retrieve, create, submit, and upload documents to a slot. + +```ruby +require 'fintoc' + +client = Fintoc::Client.new('api_key', jws_private_key: 'jws_private_key') +entity = client.v2.entities.get('entity_id') + +# List the onboardings of an entity (cursor-paginated, light shape) +onboardings = entity.onboardings.list + +# Retrieve a single onboarding (full shape, includes shareholders and documents) +onboarding = entity.onboardings.get('onboarding_id') +puts onboarding # 📋 Onboarding onbprc_0ujs... (in_progress) +onboarding.shareholders # => array of shareholder hashes +onboarding.documents # => array of document hashes + +# Create an onboarding. The nested structures are passed through as-is and +# validated by the API. +onboarding = entity.onboardings.create( + company_information: { + incorporation_date: '2020-01-01', + business_activity: 'Software', + legal_name: 'ACME Inc.', + fiscal_address: 'Av. Siempre Viva 123', + business_address: 'Av. Siempre Viva 123', + settlement_account: '1234567890', + phone: '+56912345678' + }, + legal_representative: { + first_name: 'Jane', + last_name: 'Doe', + email: 'jane@acme.com', + nationality: 'CL', + identification_number: '12345678-9', + position: 'CEO' + }, + transactional_profile: { + resource_origins: ['sales'], + monthly_amount_range: '0-1000000', + monthly_operations_range: '0-100' + }, + shareholders: [ + { + type: 'natural_person', + name: 'Jane', + last_name: 'Doe', + holder_id: '12345678-9', + nationality: 'CL', + percentage: 100 + } + ] +) + +# Upload a document for a given slot (multipart). `file:` accepts a path or an IO. +entity.onboardings.upload_document('onboarding_id', 'slot_key', file: 'path/to/file.pdf') + +# Upload a document for a specific shareholder (multipart). +entity.onboardings.upload_shareholder_document( + 'onboarding_id', 'shareholder_id', file: 'path/to/file.pdf' +) + +# Submit the onboarding for review. +onboarding = entity.onboardings.submit('onboarding_id') +``` + ## 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. From f8861274a9c98b95e3c7778da2dd4d7df6f5e525 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 17:58:20 -0400 Subject: [PATCH 5/6] fixup! feat(client): support multipart/form-data uploads --- lib/fintoc/base_client.rb | 10 +++----- spec/lib/fintoc/base_client_spec.rb | 38 ++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/fintoc/base_client.rb b/lib/fintoc/base_client.rb index 63e8a20..a011a1a 100644 --- a/lib/fintoc/base_client.rb +++ b/lib/fintoc/base_client.rb @@ -43,8 +43,8 @@ def put(version: :v1, use_jws: false, idempotency_key: nil) end def request(method, version: :v1, use_jws: false, idempotency_key: nil) - proc do |resource, **kwargs| - parameters = params(method, **kwargs) + proc do |resource, form: nil, **kwargs| + parameters = form ? { form: build_form(form) } : params(method, **kwargs) response = make_request(method, resource, parameters, version:, use_jws:, idempotency_key:) content = JSON.parse(response.body, symbolize_names: true) @@ -126,17 +126,13 @@ def make_request( end def params(method, **kwargs) - if kwargs.key?(:form) - { form: build_form(kwargs[:form]) } - elsif method == 'get' + if method == 'get' { params: { **@default_params, **kwargs } } else { json: { **@default_params, **kwargs } } end end - # Wraps file values (IO-like objects or file paths) as multipart file parts - # and leaves the rest as plain fields, so a form can mix files and fields. def build_form(form) form.transform_values do |value| file?(value) ? HTTP::FormData::File.new(value) : value diff --git a/spec/lib/fintoc/base_client_spec.rb b/spec/lib/fintoc/base_client_spec.rb index 8f37ad5..7a3a28a 100644 --- a/spec/lib/fintoc/base_client_spec.rb +++ b/spec/lib/fintoc/base_client_spec.rb @@ -175,6 +175,25 @@ end end + context 'when a form is given' do + before do + allow(mock_response).to receive_messages( + 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_return(mock_response) + end + + it 'builds a form body instead of params' do + pdf = 'spec/support/fixtures/sample_document.pdf' + client.request('put').call('test/resource', form: { file: pdf }) + + expect(client).to have_received(:make_request) + .with('put', 'test/resource', hash_including(:form), any_args) + end + end + context 'when HTTP request returns an error' do let(:error_response_body) do { @@ -325,15 +344,6 @@ end describe '#params' do - it 'wraps file form values and leaves other fields untouched' do - result = client.send(:params, 'put', - form: { file: 'spec/support/fixtures/sample_document.pdf', - description: 'contract' }) - - expect(result[:form][:file]).to be_a(HTTP::FormData::File) - expect(result[:form][:description]).to eq('contract') - end - it 'builds a JSON body for non-GET requests' do expect(client.send(:params, 'post', amount: 100)).to eq(json: { amount: 100 }) end @@ -343,6 +353,16 @@ end end + describe '#build_form' do + it 'wraps file values and leaves other fields untouched' do + result = client.send(:build_form, file: 'spec/support/fixtures/sample_document.pdf', + text: 'x') + + expect(result[:file]).to be_a(HTTP::FormData::File) + expect(result[:text]).to eq('x') + end + end + describe '#to_s' do it 'returns masked API key representation' do result = client.to_s From eef5c60d92ecbc8aef91094362407b32ef807d14 Mon Sep 17 00:00:00 2001 From: Jose Marchant Date: Mon, 22 Jun 2026 18:09:39 -0400 Subject: [PATCH 6/6] fixup! docs(v2): document entity onboardings usage --- CHANGELOG.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc98c9..1218807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,5 @@ # Changelog -## Unreleased - -### 🚀 New Features - -- **Onboardings**: Added the full onboarding lifecycle under v2 entities. Onboardings are nested off an `Entity` instance via `entity.onboardings`: - - `list`, `get`, and `create` (the create body is passed through as-is and validated by the API) - - `submit` to send an onboarding for review - - `upload_document` and `upload_shareholder_document` for multipart document uploads -- **Multipart uploads**: Added multipart `form` support and a `put` verb to the HTTP layer to back the onboarding document uploads (uploads are not JWS-signed). -- **Entity attributes**: Added `status` and `country_code` to the v2 `Entity` resource. - ## 1.2.0 - 2026-04-08 ### 🚀 New Features