From b74556dfce55c30ebcc28307ed121895c944d1d8 Mon Sep 17 00:00:00 2001 From: LovroColic Date: Thu, 23 Jan 2025 14:46:46 +0100 Subject: [PATCH] feat(preview-invoice): add tax provider support for preview invoice (#3095) ## Context Preview feature enables fetching first Lago invoice. This invoice is not persisted and is calculated on the fly. ## Description This PR adds tax provider logic for preview invoice --- .../aggregator/taxes/base_service.rb | 11 +-- app/services/invoices/preview_service.rb | 76 +++++++++++++-- .../services/invoices/preview_service_spec.rb | 93 ++++++++++++++++++- 3 files changed, 167 insertions(+), 13 deletions(-) diff --git a/app/services/integrations/aggregator/taxes/base_service.rb b/app/services/integrations/aggregator/taxes/base_service.rb index 22a7f56851c..c7b71f9a101 100644 --- a/app/services/integrations/aggregator/taxes/base_service.rb +++ b/app/services/integrations/aggregator/taxes/base_service.rb @@ -18,11 +18,10 @@ def integration end def integration_customer - @integration_customer ||= - customer - .integration_customers - .where(type: 'IntegrationCustomers::AnrokCustomer') - .first + @integration_customer ||= begin + int_customers = customer.integration_customers + int_customers.find { |ic| ic.type == 'IntegrationCustomers::AnrokCustomer' } + end end def headers @@ -63,7 +62,7 @@ def process_response(body) message = 'API limit' if message == 'Internal server error: resource contention.' unless message.include?('API limit') - deliver_tax_error_webhook(customer:, code:, message:) + deliver_tax_error_webhook(customer:, code:, message:) if customer.persisted? # Do not send this webhook in preview mode end result.service_failure!(code:, message:) diff --git a/app/services/invoices/preview_service.rb b/app/services/invoices/preview_service.rb index 06a5852c947..e569bba352a 100644 --- a/app/services/invoices/preview_service.rb +++ b/app/services/invoices/preview_service.rb @@ -92,14 +92,12 @@ def compute_tax_and_totals invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents - invoice.coupons_amount_cents - invoice.fees.each do |fee| - taxes_result = Fees::ApplyTaxesService.call(fee:) - taxes_result.raise_if_error! + if provider_taxation? + apply_provider_taxes + else + apply_taxes end - taxes_result = Invoices::ApplyTaxesService.call(invoice:) - taxes_result.raise_if_error! - invoice.sub_total_including_taxes_amount_cents = ( invoice.sub_total_excluding_taxes_amount_cents + invoice.taxes_amount_cents ) @@ -139,5 +137,71 @@ def wallet @wallet = customer.wallets.active.first end + + def apply_taxes + invoice.fees.each do |fee| + taxes_result = Fees::ApplyTaxesService.call(fee:) + taxes_result.raise_if_error! + end + + taxes_result = Invoices::ApplyTaxesService.call(invoice:) + taxes_result.raise_if_error! + end + + def apply_provider_taxes + taxes_result = fetch_provider_taxes + + if taxes_result.success? + result.fees_taxes = taxes_result.fees + invoice.fees.each do |fee| + fee_taxes = result.fees_taxes.find { |item| item.item_id == fee.id } + + res = Fees::ApplyProviderTaxesService.call(fee:, fee_taxes:) + res.raise_if_error! + end + + res = Invoices::ApplyProviderTaxesService.call(invoice:, provider_taxes: result.fees_taxes) + res.raise_if_error! + else + apply_zero_tax + end + rescue BaseService::ThrottlingError + apply_zero_tax + end + + def provider_taxes_cache_key + [ + "preview-taxes", + customer.id, + subscription.plan.updated_at.iso8601 + ].join("/") + end + + def apply_zero_tax + invoice.taxes_amount_cents = 0 + invoice.taxes_rate = 0 + end + + def fetch_provider_taxes + if customer.persisted? + taxes_result = Rails.cache.read(provider_taxes_cache_key) + + unless taxes_result + taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call( + invoice:, + fees: invoice.fees + ) + Rails.cache.write(provider_taxes_cache_key, taxes_result, expires_in: 24.hours) if taxes_result.success? + end + + taxes_result + else + Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:, fees: invoice.fees) + end + end + + def provider_taxation? + customer.integration_customers&.find { |ic| ic.type == 'IntegrationCustomers::AnrokCustomer' } + end end end diff --git a/spec/services/invoices/preview_service_spec.rb b/spec/services/invoices/preview_service_spec.rb index 1a74a92a404..153396471ac 100644 --- a/spec/services/invoices/preview_service_spec.rb +++ b/spec/services/invoices/preview_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Invoices::PreviewService, type: :service do +RSpec.describe Invoices::PreviewService, type: :service, cache: :memory do subject(:preview_service) { described_class.new(customer:, subscription:) } describe '#call' do @@ -169,6 +169,97 @@ end end end + + context 'with provider taxes' do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { build(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { 'https://api.nango.dev/v1/anrok/draft_invoices' } + let(:body) do + p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response.json') + File.read(p) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: '1', external_account_code: '11', external_name: ''} + ) + end + + before do + integration_collection_mapping + customer.integration_customers = [integration_customer] + + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + allow_any_instance_of(Fee).to receive(:id).and_return('lago_fee_id') # rubocop:disable RSpec/AnyInstance + end + + it 'creates preview invoice for 2 days' do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq('subscription') + expect(result.invoice.issuing_date.to_s).to eq('2024-04-01') + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(1) # 6 x 0.1 + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(7) + expect(result.invoice.total_amount_cents).to eq(7) + end + end + + context 'when there is error received from the provider' do + let(:body) do + p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') + File.read(p) + end + + it 'uses zero taxes' do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq('subscription') + expect(result.invoice.issuing_date.to_s).to eq('2024-04-01') + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(6) + expect(result.invoice.total_amount_cents).to eq(6) + end + end + end + + context 'with rails cache' do + let(:customer) { create(:customer, organization:) } + + before { Rails.cache.clear } + + it 'uses the Rails cache' do + travel_to(timestamp) do + key = [ + 'preview-taxes', + customer.id, + plan.updated_at.iso8601 + ].join('/') + + expect do + preview_service.call + end.to change { Rails.cache.exist?(key) }.from(false).to(true) + end + end + end + end end context 'with anniversary billing' do