From 0eace7c95708390a31e9e0255cc974fac816b349 Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Fri, 20 Dec 2024 18:09:13 +0100 Subject: [PATCH 1/6] base refactoring for making anrok async --- app/jobs/bill_subscription_job.rb | 11 -- .../invoices/calculate_fees_service.rb | 46 +------- .../compute_taxes_and_totals_service.rb | 38 ++++++ .../invoices/progressive_billing_service.rb | 35 +----- .../refresh_draft_and_finalize_service.rb | 2 +- .../invoices/refresh_draft_service.rb | 11 +- app/services/invoices/retry_service.rb | 109 +----------------- app/services/invoices/subscription_service.rb | 7 +- .../recalculate_and_check_service.rb | 4 +- 9 files changed, 59 insertions(+), 204 deletions(-) create mode 100644 app/services/invoices/compute_taxes_and_totals_service.rb diff --git a/app/jobs/bill_subscription_job.rb b/app/jobs/bill_subscription_job.rb index b6a785c2ae1..8be203e5bbe 100644 --- a/app/jobs/bill_subscription_job.rb +++ b/app/jobs/bill_subscription_job.rb @@ -20,9 +20,6 @@ def perform(subscriptions, timestamp, invoicing_reason:, invoice: nil, skip_char skip_charges: ) return if result.success? - # NOTE: We don't want a dead job for failed invoice due to the tax reason. - # This invoice should be in failed status and can be retried. - return if tax_error?(result) # If the invoice was passed as an argument, it means the job was already retried (see end of function) if invoice || !result.invoice&.generating? @@ -41,12 +38,4 @@ def perform(subscriptions, timestamp, invoicing_reason:, invoice: nil, skip_char skip_charges: ) end - - private - - def tax_error?(result) - return false unless result.error.is_a?(BaseService::ValidationFailure) - - result.error&.messages&.dig(:tax_error)&.present? - end end diff --git a/app/services/invoices/calculate_fees_service.rb b/app/services/invoices/calculate_fees_service.rb index a4527ee79a6..06179d5c65f 100644 --- a/app/services/invoices/calculate_fees_service.rb +++ b/app/services/invoices/calculate_fees_service.rb @@ -47,23 +47,10 @@ def call Credits::ProgressiveBillingService.call(invoice:) Credits::AppliedCouponsService.call(invoice:) if should_create_coupon_credit? - if customer_provider_taxation? - taxes_result = fetch_taxes_for_invoice + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:, finalizing: finalizing_invoice?) + return totals_result if !totals_result.success? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) - unless taxes_result.success? - create_error_detail(taxes_result.error) - - # only fail invoices that are finalizing - invoice.failed! if finalizing_invoice? - - invoice.save! - return result.service_failure!(code: 'tax_error', message: taxes_result.error.code) - end - - Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes: taxes_result.fees) - else - Invoices::ComputeAmountsFromFees.call(invoice:) - end + totals_result.raise_if_error! create_credit_note_credit if should_create_credit_note_credit? create_applied_prepaid_credit if should_create_applied_prepaid_credit? @@ -337,33 +324,6 @@ def in_trial_period_not_ending_today?(subscription, timestamp) timestamp.in_time_zone(tz).to_date != subscription.trial_end_datetime.in_time_zone(tz).to_date end - def customer_provider_taxation? - @customer_provider_taxation ||= invoice.customer.anrok_customer - end - - def create_error_detail(error) - error_result = ErrorDetails::CreateService.call( - owner: invoice, - organization: invoice.organization, - params: { - error_code: :tax_error, - details: { - tax_error: error.code - }.tap do |details| - details[:tax_error_message] = error.error_message if error.code == 'validationError' - end - } - ) - error_result.raise_if_error! - end - - def fetch_taxes_for_invoice - if finalizing_invoice? - return Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:, fees: invoice.fees) - end - Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:, fees: invoice.fees) - end - def finalizing_invoice? context == :finalize || Invoice::GENERATED_INVOICE_STATUSES.include?(invoice.status) end diff --git a/app/services/invoices/compute_taxes_and_totals_service.rb b/app/services/invoices/compute_taxes_and_totals_service.rb new file mode 100644 index 00000000000..16c0f74731c --- /dev/null +++ b/app/services/invoices/compute_taxes_and_totals_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Invoices + class ComputeTaxesAndTotalsService < BaseService + def initialize(invoice:, finalizing: true) + @invoice = invoice + @finalizing = finalizing + + super + end + + def call + return result.not_found_failure!(resource: 'invoice') unless invoice + + if customer_provider_taxation? + invoice.status = 'pending' if finalizing + invoice.tax_status = 'pending' + invoice.save! + after_commit { Invoices::ProviderTaxes::PullTaxesAndApplyJob.perform_later(invoice:) } + + return result.unknown_tax_failure!(code: 'tax_error', message: 'unknown taxes') + else + Invoices::ComputeAmountsFromFees.call(invoice:) + end + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :finalizing + + def customer_provider_taxation? + @customer_provider_taxation ||= invoice.customer.anrok_customer + end + end +end diff --git a/app/services/invoices/progressive_billing_service.rb b/app/services/invoices/progressive_billing_service.rb index d77e19cb45f..514b11a8f6c 100644 --- a/app/services/invoices/progressive_billing_service.rb +++ b/app/services/invoices/progressive_billing_service.rb @@ -22,19 +22,10 @@ def call Credits::ProgressiveBillingService.call(invoice:) Credits::AppliedCouponsService.call(invoice:) - if customer_provider_taxation? - taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:, fees: invoice.fees) + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) + return totals_result if !totals_result.success? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) - unless taxes_result.success? - create_error_detail(taxes_result.error) - invoice.failed! - - return result.service_failure!(code: 'tax_error', message: taxes_result.error.code) - end - Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes: taxes_result.fees) - else - Invoices::ComputeAmountsFromFees.call(invoice:) - end + totals_result.raise_if_error! create_credit_note_credit create_applied_prepaid_credit @@ -149,25 +140,5 @@ def create_applied_prepaid_credit invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents end - - def customer_provider_taxation? - @customer_provider_taxation ||= invoice.customer.anrok_customer - end - - def create_error_detail(error) - error_result = ErrorDetails::CreateService.call( - owner: invoice, - organization: invoice.organization, - params: { - error_code: :tax_error, - details: { - tax_error: error.code - }.tap do |details| - details[:tax_error_message] = error.error_message if error.code == 'validationError' - end - } - ) - error_result.raise_if_error! - end end end diff --git a/app/services/invoices/refresh_draft_and_finalize_service.rb b/app/services/invoices/refresh_draft_and_finalize_service.rb index 07dc0edb0b0..02c2435b63d 100644 --- a/app/services/invoices/refresh_draft_and_finalize_service.rb +++ b/app/services/invoices/refresh_draft_and_finalize_service.rb @@ -15,7 +15,7 @@ def call ActiveRecord::Base.transaction do invoice.issuing_date = issuing_date refresh_result = Invoices::RefreshDraftService.call(invoice:, context: :finalize) - if tax_error?(refresh_result.error) + if invoice.tax_pending? invoice.update!(issuing_date: drafted_issuing_date) return refresh_result end diff --git a/app/services/invoices/refresh_draft_service.rb b/app/services/invoices/refresh_draft_service.rb index f0675b135ac..1fb900423cb 100644 --- a/app/services/invoices/refresh_draft_service.rb +++ b/app/services/invoices/refresh_draft_service.rb @@ -63,10 +63,7 @@ def call CreditNotes::RefreshDraftService.call(credit_note:, fee:, old_fee_values:) end - if tax_error?(calculate_result.error) - return result.validation_failure!(errors: {tax_error: [calculate_result.error.error_message]}) - end - calculate_result.raise_if_error! + calculate_result.raise_if_error! unless tax_error?(calculate_result.error) if old_total_amount_cents != invoice.total_amount_cents flag_lifetime_usage_for_refresh @@ -76,6 +73,8 @@ def call # NOTE: In case of a refresh the same day of the termination. invoice.fees.update_all(created_at: invoice.created_at) # rubocop:disable Rails/SkipsModelValidations + return result if tax_error?(calculate_result.error) + if invoice.should_update_hubspot_invoice? Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice: invoice.reload) end @@ -110,9 +109,7 @@ def flag_lifetime_usage_for_refresh end def tax_error?(error) - return false unless error.is_a?(BaseService::ServiceFailure) - - error&.code == 'tax_error' + error && error.is_a?(BaseService::UnknownTaxFailure) end def reset_invoice_values diff --git a/app/services/invoices/retry_service.rb b/app/services/invoices/retry_service.rb index 12f028947b8..2e0ce5306e6 100644 --- a/app/services/invoices/retry_service.rb +++ b/app/services/invoices/retry_service.rb @@ -12,117 +12,18 @@ def call return result.not_found_failure!(resource: 'invoice') unless invoice return result.not_allowed_failure!(code: 'invalid_status') unless invoice.failed? - invoice.error_details.tax_error.discard_all - taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:, fees: invoice.fees) + invoice.status = 'pending' + invoice.tax_status = 'pending' + invoice.save! - unless taxes_result.success? - create_error_detail(taxes_result.error) - return result.validation_failure!(errors: {tax_error: [taxes_result.error.code]}) - end - - provider_taxes = taxes_result.fees - - ActiveRecord::Base.transaction do - invoice.issuing_date = issuing_date - invoice.payment_due_date = payment_due_date - - Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes:) - - create_credit_note_credit if should_create_credit_note_credit? - create_applied_prepaid_credit if should_create_applied_prepaid_credit? - - invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded - invoice.status = :finalized - invoice.save! - - invoice.reload - - result.invoice = invoice - end - - SendWebhookJob.perform_later('invoice.created', invoice) - GeneratePdfAndNotifyJob.perform_later(invoice:, email: should_deliver_email?) - Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? - Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? - Invoices::Payments::CreateService.call_async(invoice:) - Utils::SegmentTrack.invoice_created(invoice) + Invoices::ProviderTaxes::PullTaxesAndApplyJob.perform_later(invoice:) + result.invoice = invoice result - rescue ActiveRecord::RecordInvalid => e - result.record_validation_failure!(record: e.record) - rescue BaseService::FailedResult => e - e.result - rescue => e - result.fail_with_error!(e) end private attr_accessor :invoice - - def should_deliver_email? - License.premium? && - invoice.organization.email_settings.include?('invoice.finalized') - end - - def wallet - return @wallet if @wallet - - @wallet = customer.wallets.active.first - end - - def should_create_credit_note_credit? - !invoice.one_off? - end - - def should_create_applied_prepaid_credit? - return false if invoice.one_off? - return false unless wallet&.active? - return false unless invoice.total_amount_cents&.positive? - - wallet.balance.positive? - end - - def create_credit_note_credit - credit_result = Credits::CreditNoteService.new(invoice:).call - credit_result.raise_if_error! - - invoice.total_amount_cents -= credit_result.credits.sum(&:amount_cents) if credit_result.credits - end - - def create_applied_prepaid_credit - prepaid_credit_result = Credits::AppliedPrepaidCreditService.call(invoice:, wallet:) - prepaid_credit_result.raise_if_error! - - invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents - end - - def issuing_date - @issuing_date ||= Time.current.in_time_zone(customer.applicable_timezone).to_date - end - - def payment_due_date - @payment_due_date ||= issuing_date + customer.applicable_net_payment_term.days - end - - def customer - @customer ||= invoice.customer - end - - def create_error_detail(error) - error_result = ErrorDetails::CreateService.call( - owner: invoice, - organization: invoice.organization, - params: { - error_code: :tax_error, - details: { - tax_error: error.code - }.tap do |details| - details[:tax_error_message] = error.error_message if error.code == 'validationError' - end - } - ) - error_result.raise_if_error! - end end end diff --git a/app/services/invoices/subscription_service.rb b/app/services/invoices/subscription_service.rb index eb2a436200d..2a5ce41ee3e 100644 --- a/app/services/invoices/subscription_service.rb +++ b/app/services/invoices/subscription_service.rb @@ -34,7 +34,7 @@ def call context: ) - set_invoice_generated_status unless invoice.failed? + set_invoice_generated_status unless invoice.pending? invoice.save! # NOTE: We don't want to raise error and corrupt DB commit if there is tax error. @@ -61,7 +61,7 @@ def call if tax_error?(fee_result) SendWebhookJob.perform_later("invoice.drafted", invoice) if grace_period? - return result.validation_failure!(errors: {tax_error: [fee_result.error.error_message]}) + return result end if grace_period? @@ -147,9 +147,8 @@ def flag_lifetime_usage_for_refresh def tax_error?(fee_result) return false if fee_result.success? - return false unless fee_result.error.is_a?(BaseService::ServiceFailure) - fee_result.error.code == "tax_error" + fee_result.error.is_a?(BaseService::UnknownTaxFailure) end USAGE_TRACKABLE_REASONS = %i[subscription_periodic subscription_terminating].freeze diff --git a/app/services/lifetime_usages/recalculate_and_check_service.rb b/app/services/lifetime_usages/recalculate_and_check_service.rb index 128f0cbc3d7..f0d6380204e 100644 --- a/app/services/lifetime_usages/recalculate_and_check_service.rb +++ b/app/services/lifetime_usages/recalculate_and_check_service.rb @@ -35,9 +35,9 @@ def progressive_billed_amount end def tax_error?(result) - return false unless result.error.is_a?(BaseService::ServiceFailure) + return false if result.success? - !result.success? && result.error&.code == 'tax_error' + result.error.is_a?(BaseService::UnknownTaxFailure) end end end From 64d8697e8505401f9aa4d5cea27accba097b363d Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Mon, 23 Dec 2024 14:42:14 +0100 Subject: [PATCH 2/6] update specs --- spec/graphql/mutations/invoices/retry_spec.rb | 33 +-- spec/jobs/bill_subscription_job_spec.rb | 12 - .../invoices/calculate_fees_service_spec.rb | 138 ++-------- .../compute_taxes_and_totals_service_spec.rb | 123 +++++++++ .../progressive_billing_service_spec.rb | 91 +------ ...refresh_draft_and_finalize_service_spec.rb | 75 +----- .../invoices/refresh_draft_service_spec.rb | 57 +--- spec/services/invoices/retry_service_spec.rb | 253 +----------------- .../invoices/subscription_service_spec.rb | 65 +---- .../recalculate_and_check_service_spec.rb | 4 +- 10 files changed, 180 insertions(+), 671 deletions(-) create mode 100644 spec/services/invoices/compute_taxes_and_totals_service_spec.rb diff --git a/spec/graphql/mutations/invoices/retry_spec.rb b/spec/graphql/mutations/invoices/retry_spec.rb index b25105f5920..21129f3b07e 100644 --- a/spec/graphql/mutations/invoices/retry_spec.rb +++ b/spec/graphql/mutations/invoices/retry_spec.rb @@ -41,30 +41,6 @@ amount_cents: 2_000 ) end - - let(:integration) { create(:anrok_integration, organization:) } - let(:integration_customer) { create(: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/finalized_invoices' } - let(:body) do - path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response.json') - json = File.read(path) - - # setting item_id based on the test example - response = JSON.parse(json) - response['succeededInvoices'].first['fees'].first['item_id'] = fee_subscription.id - - response.to_json - 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 let(:mutation) do <<-GQL mutation($input: RetryInvoiceInput!) { @@ -77,14 +53,7 @@ end before do - integration_collection_mapping fee_subscription - - 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) end it_behaves_like 'requires current user' @@ -106,7 +75,7 @@ data = result['data']['retryInvoice'] expect(data['id']).to eq(invoice.id) - expect(data['status']).to eq('finalized') + expect(data['status']).to eq('pending') end end end diff --git a/spec/jobs/bill_subscription_job_spec.rb b/spec/jobs/bill_subscription_job_spec.rb index 81ef36a04f3..4d7707c5c7d 100644 --- a/spec/jobs/bill_subscription_job_spec.rb +++ b/spec/jobs/bill_subscription_job_spec.rb @@ -37,18 +37,6 @@ expect(Invoices::SubscriptionService).to have_received(:call) end - context 'when there is tax error' do - let(:result) do - BaseService::Result.new.validation_failure!(errors: {tax_error: ['invalidMapping']}) - end - - it 'does not throw an error' do - expect do - described_class.perform_now(subscriptions, timestamp, invoicing_reason:) - end.not_to raise_error - end - end - context 'with a previously created invoice' do let(:invoice) { create(:invoice, :generating) } diff --git a/spec/services/invoices/calculate_fees_service_spec.rb b/spec/services/invoices/calculate_fees_service_spec.rb index d22761bd9c8..d414da75957 100644 --- a/spec/services/invoices/calculate_fees_service_spec.rb +++ b/spec/services/invoices/calculate_fees_service_spec.rb @@ -160,101 +160,24 @@ context 'when there is tax provider integration' do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(: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/finalized_invoices' } - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.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 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(Integrations::Aggregator::Taxes::Invoices::CreateDraftService).to receive(:call).and_call_original - allow(Integrations::Aggregator::Taxes::Invoices::CreateService).to receive(:call).and_call_original - allow_any_instance_of(Fee).to receive(:id).and_wrap_original do |m, *args| # rubocop:disable RSpec/AnyInstance - fee = m.receiver - if fee.charge_id == charge.id - 'charge_fee_id-12345' - elsif fee.subscription_id == subscription.id - 'sub_fee_id-12345' - else - m.call(*args) - end - end end - it 'creates fees' do + it 'returns tax unknown error and puts invoice in valid status' do result = invoice_service.call aggregate_failures do - expect(result).to be_success - - expect(result.invoice.fees_amount_cents).to eq(100) - expect(result.invoice.taxes_amount_cents).to eq(10) + expect(result).not_to be_success + expect(result.error.code).to eq('tax_error') + expect(result.error.error_message).to eq('unknown taxes') - expect(result.invoice.reload.error_details.count).to eq(0) - end - end - - it 'fetches taxes using finalized invoices service' do - invoice_service.call - expect(Integrations::Aggregator::Taxes::Invoices::CreateService).to have_received(:call) - 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 'returns tax error' do - result = invoice_service.call - - aggregate_failures do - expect(result).not_to be_success - expect(result.error.code).to eq('tax_error') - expect(result.error.error_message).to eq('taxDateTooFarInFuture') - - expect(invoice.reload.status).to eq('failed') - expect(invoice.reload.error_details.count).to eq(1) - expect(invoice.reload.error_details.first.details['tax_error']).to eq('taxDateTooFarInFuture') - end - end - - context 'with api limit error' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json') - File.read(p) - end - - it 'returns and store proper error details' do - result = invoice_service.call - - aggregate_failures do - expect(result).not_to be_success - expect(result.error.code).to eq('tax_error') - expect(result.error.error_message).to eq('validationError') - - expect(invoice.reload.error_details.count).to eq(1) - expect(invoice.reload.error_details.first.details['tax_error']).to eq('validationError') - expect(invoice.reload.error_details.first.details['tax_error_message']) - .to eq("You've exceeded your API limit of 10 per second") - end - end + expect(invoice.reload.status).to eq('pending') + expect(invoice.reload.tax_status).to eq('pending') + expect(invoice.reload.fees_amount_cents).to eq(100) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) end end @@ -271,48 +194,41 @@ end context 'when no context is passed' do - let(:endpoint) { 'https://api.nango.dev/v1/anrok/draft_invoices' } - - it 'creates fees' do + it 'returns tax unknown error and puts invoice in valid status' do result = invoice_service.call aggregate_failures do - expect(result).to be_success - - expect(result.invoice.fees_amount_cents).to eq(100) - expect(result.invoice.taxes_amount_cents).to eq(10) + expect(result).not_to be_success + expect(result.error.code).to eq('tax_error') + expect(result.error.error_message).to eq('unknown taxes') - expect(result.invoice.reload.error_details.count).to eq(0) + expect(invoice.reload.status).to eq('draft') + expect(invoice.reload.tax_status).to eq('pending') + expect(invoice.reload.fees_amount_cents).to eq(100) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) end end - - it 'fetches taxes using draft invoices service' do - invoice_service.call - expect(Integrations::Aggregator::Taxes::Invoices::CreateDraftService).to have_received(:call) - end end context 'when context is :finalize' do - let(:endpoint) { 'https://api.nango.dev/v1/anrok/finalized_invoices' } let(:context) { :finalize } - it 'creates fees' do + it 'returns tax unknown error and puts invoice in valid status' do result = invoice_service.call aggregate_failures do - expect(result).to be_success - - expect(result.invoice.fees_amount_cents).to eq(100) - expect(result.invoice.taxes_amount_cents).to eq(10) + expect(result).not_to be_success + expect(result.error.code).to eq('tax_error') + expect(result.error.error_message).to eq('unknown taxes') - expect(result.invoice.reload.error_details.count).to eq(0) + expect(invoice.reload.status).to eq('pending') + expect(invoice.reload.tax_status).to eq('pending') + expect(invoice.reload.fees_amount_cents).to eq(100) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) end end - - it 'fetches taxes using finalized invoices service' do - invoice_service.call - expect(Integrations::Aggregator::Taxes::Invoices::CreateService).to have_received(:call) - end end end end diff --git a/spec/services/invoices/compute_taxes_and_totals_service_spec.rb b/spec/services/invoices/compute_taxes_and_totals_service_spec.rb new file mode 100644 index 00000000000..752b0fd6820 --- /dev/null +++ b/spec/services/invoices/compute_taxes_and_totals_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Invoices::ComputeTaxesAndTotalsService, type: :service do + subject(:totals_service) { described_class.new(invoice:) } + + describe '#call' do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :finalized, + customer:, + organization:, + subscriptions: [subscription], + currency: 'EUR', + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: 'monthly') } + let(:billable_metric) { create(:billable_metric, aggregation_type: 'count_agg') } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: 'standard', billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + before do + fee_subscription + fee_charge + end + + context 'when invoice does not exist' do + it 'returns an error' do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq('invoice_not_found') + end + end + + context 'when there is tax provider' do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + it 'enqueues a Invoices::ProviderTaxes::PullTaxesAndApplyJob' do + expect do + totals_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + + it 'sets correct statuses on invoice' do + totals_service.call + + expect(invoice.reload.status).to eq('pending') + expect(invoice.reload.tax_status).to eq('pending') + end + + context 'when invoice is draft' do + before { invoice.update!(status: :draft) } + + it 'sets only tax status' do + described_class.new(invoice:, finalizing: false).call + + expect(invoice.reload.status).to eq('draft') + expect(invoice.reload.tax_status).to eq('pending') + end + end + end + + context 'when there is NO tax provider' do + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ComputeAmountsFromFees).to receive(:call) + .with(invoice:) + .and_return(result) + end + + it 'calls the add on create service' do + totals_service.call + + expect(Invoices::ComputeAmountsFromFees).to have_received(:call) + end + end + end +end diff --git a/spec/services/invoices/progressive_billing_service_spec.rb b/spec/services/invoices/progressive_billing_service_spec.rb index a36a1f78d2f..3977b46faf4 100644 --- a/spec/services/invoices/progressive_billing_service_spec.rb +++ b/spec/services/invoices/progressive_billing_service_spec.rb @@ -71,105 +71,24 @@ context 'when there is tax provider integration' do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(: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/finalized_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 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(Integrations::Aggregator::Taxes::Invoices::CreateService).to receive(:call).and_call_original - allow_any_instance_of(Fee).to receive(:id).and_return('lago_fee_id') # rubocop:disable RSpec/AnyInstance end - it 'creates a progressive billing invoice', aggregate_failures: true do - result = create_service.call - - expect(result).to be_success - expect(result.invoice).to be_present - - invoice = result.invoice - amount_cents = 100 - taxes_amount_cents = amount_cents * 10 / 100 - - expect(invoice).to be_persisted - expect(invoice).to have_attributes( - organization: organization, - customer: customer, - currency: plan.amount_currency, - status: 'finalized', - invoice_type: 'progressive_billing', - fees_amount_cents: amount_cents, - taxes_amount_cents:, - total_amount_cents: amount_cents + taxes_amount_cents - ) - - expect(invoice.invoice_subscriptions.count).to eq(1) - expect(invoice.fees.count).to eq(1) - expect(invoice.applied_usage_thresholds.count).to eq(1) - - expect(invoice.applied_usage_thresholds.first.lifetime_usage_amount_cents) - .to eq(lifetime_usage.total_amount_cents) - 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 - + context 'when taxes are unknown' do it 'returns tax error', aggregate_failures: true do result = create_service.call expect(result).not_to be_success expect(result.error.code).to eq('tax_error') - expect(result.error.error_message).to eq('taxDateTooFarInFuture') + expect(result.error.error_message).to eq('unknown taxes') invoice = customer.invoices.order(created_at: :desc).first - expect(invoice.status).to eq('failed') - expect(invoice.error_details.count).to eq(1) - expect(invoice.error_details.first.details['tax_error']).to eq('taxDateTooFarInFuture') - end - - context 'with api limit error' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json') - File.read(p) - end - - it 'returns and store proper error details' do - result = create_service.call - - aggregate_failures do - expect(result).not_to be_success - expect(result.error.code).to eq('tax_error') - expect(result.error.error_message).to eq('validationError') - - invoice = customer.invoices.order(created_at: :desc).first - - expect(invoice.reload.error_details.count).to eq(1) - expect(invoice.reload.error_details.first.details['tax_error']).to eq('validationError') - expect(invoice.reload.error_details.first.details['tax_error_message']) - .to eq("You've exceeded your API limit of 10 per second") - end - end + expect(invoice.status).to eq('pending') + expect(invoice.tax_status).to eq('pending') + expect(invoice.error_details.count).to eq(0) end end end diff --git a/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb index c544679b2f2..c16e9f10714 100644 --- a/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb +++ b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb @@ -203,93 +203,30 @@ context 'when tax integration is set up' do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(: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/finalized_invoices' } - 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 integration_customer invoice.update(issuing_date: Time.current + 3.months) - 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(Invoices::ApplyProviderTaxesService).to receive(:call).and_call_original allow(SendWebhookJob).to receive(:perform_later).and_call_original allow(Invoices::GeneratePdfAndNotifyJob).to receive(:perform_later).and_call_original allow(Integrations::Aggregator::Invoices::CreateJob).to receive(:perform_later).and_call_original allow(Invoices::Payments::CreateService).to receive(:new).and_call_original allow(Utils::SegmentTrack).to receive(:invoice_created).and_call_original - allow_any_instance_of(Fee).to receive(:id).and_wrap_original do |m, *args| # rubocop:disable RSpec/AnyInstance - fee = m.receiver - if fee.charge_id == standard_charge.id - 'charge_fee_id-12345' - elsif fee.subscription_id == subscription.id - 'sub_fee_id-12345' - else - m.call(*args) - end - end end - context 'when taxes fetched correctly' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json') - File.read(p) - end - let(:invoice_issuing_date) { Time.current.in_time_zone(invoice.customer.applicable_timezone).to_date } - - it 'refreshes all data and applies fetched taxes' do - aggregate_failures do - expect { finalize_service.call }.to change { invoice.reload.taxes_rate }.from(0.0).to(10.0) - .and change { invoice.fees.count }.from(0).to(2) - expect(LagoHttpClient::Client).to have_received(:new).with(endpoint) - expect(Invoices::ApplyProviderTaxesService).to have_received(:call) - end - end - - it 'finalizes the invoice' do - expect { finalize_service.call }.to change { invoice.reload.status }.from('draft').to('finalized') - end - - it 'sends finalized invoice issuing date to tax_provider' do - finalize_service.call - expect(lago_client).to have_received(:post_with_response) - .with([hash_including('issuing_date' => invoice_issuing_date)], anything) - end - end - - context 'when fetched taxes with errors' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') - File.read(p) - end - - it 'returns error' do + context 'when taxes are unknown' do + it 'returns pending invoice' do result = finalize_service.call aggregate_failures do - expect(invoice.reload.status).to eql('failed') - expect(result.success?).to be(false) - expect(result.error).to be_a(BaseService::ValidationFailure) - expect(result.error.messages[:tax_error]).to eq(['taxDateTooFarInFuture']) + expect(invoice.reload.status).to eql('pending') + expect(result.success?).to be(true) end end - it 'moves invoice to failed state' do - expect { finalize_service.call }.to change(invoice.reload, :status).from('draft').to('failed') - end - - it 'creates a new error_detail for the invoice' do - expect { finalize_service.call }.to change(invoice.error_details, :count).from(0).to(1) + it 'moves invoice to pending tax state' do + expect { finalize_service.call }.to change(invoice.reload, :tax_status).from(nil).to('pending') end it 'updates fees despite error result' do diff --git a/spec/services/invoices/refresh_draft_service_spec.rb b/spec/services/invoices/refresh_draft_service_spec.rb index e1b8c990d65..590e0d4860d 100644 --- a/spec/services/invoices/refresh_draft_service_spec.rb +++ b/spec/services/invoices/refresh_draft_service_spec.rb @@ -174,71 +174,22 @@ context 'when there is a tax_integration set up' do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(: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(:integration_collection_mapping) do - create( - :netsuite_collection_mapping, - integration:, - mapping_type: :fallback_item, - settings: {external_id: '1', external_account_code: '11', external_name: ''} - ) - end let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: 'standard') } before do - integration_collection_mapping integration_customer charge - - 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_wrap_original do |m, *args| # rubocop:disable RSpec/AnyInstance - fee = m.receiver - if fee.charge_id == charge.id - 'charge_fee_id-12345' - elsif fee.subscription_id == subscription.id - 'sub_fee_id-12345' - else - m.call(*args) - end - end - end - - context 'when successfully fetching taxes' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json') - File.read(p) - end - - it 'successfully applies taxes and regenerates fees' do - expect { refresh_service.call }.to change { invoice.reload.taxes_rate }.from(30.0).to(10.0) - .and change { invoice.fees.count }.from(0).to(2) - end end - context 'when failed to fetch taxes' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') - json = File.read(p) - response = JSON.parse(json) - response.to_json - end - + context 'when taxes are unknown' do it 'regenerates fees' do expect { refresh_service.call }.to change { invoice.fees.count }.from(0).to(2) end - it 'creates error_detail for the invoice' do - result = refresh_service.call + it 'sets correct tax status' do + refresh_service.call - aggregate_failures do - expect(result.success?).to be(false) - expect(invoice.reload.error_details.count).to eq(1) - expect(invoice.error_details.last.error_code).to include('tax_error') - end + expect(invoice.reload.tax_status).to eq('pending') end it 'resets invoice values to calculatable before the error' do diff --git a/spec/services/invoices/retry_service_spec.rb b/spec/services/invoices/retry_service_spec.rb index 46b288cbd3e..942efc33fe9 100644 --- a/spec/services/invoices/retry_service_spec.rb +++ b/spec/services/invoices/retry_service_spec.rb @@ -58,44 +58,9 @@ ) end - let(:integration) { create(:anrok_integration, organization:) } - let(:integration_customer) { create(: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/finalized_invoices' } - let(:body) do - path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json') - json = File.read(path) - - # setting item_id based on the test example - response = JSON.parse(json) - response['succeededInvoices'].first['fees'].first['item_id'] = fee_subscription.id - response['succeededInvoices'].first['fees'].last['item_id'] = fee_charge.id - - response.to_json - 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 fee_subscription fee_charge - - allow(SegmentTrackJob).to receive(:perform_later) - allow(Invoices::Payments::CreateService).to receive(:call_async).and_call_original - - 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) end context 'when invoice does not exist' do @@ -120,221 +85,17 @@ end end - context 'when taxes are fetched successfully' do - it 'marks the invoice as finalized' do - expect { retry_service.call } - .to change(invoice, :status).from('failed').to('finalized') - end - - it 'discards previous tax errors' do - expect { retry_service.call } - .to change(invoice.error_details.tax_error, :count).from(1).to(0) - end - - it 'updates the issuing date and payment due date' do - invoice.customer.update(timezone: 'America/New_York') - - freeze_time do - current_date = Time.current.in_time_zone('America/New_York').to_date - - expect { retry_service.call } - .to change { invoice.reload.issuing_date }.to(current_date) - .and change { invoice.reload.payment_due_date }.to(current_date) - end - end - - it 'generates invoice number' do - customer_slug = "#{organization.document_number_prefix}-#{format("%03d", customer.sequential_id)}" - sequential_id = customer.invoices.where.not(id: invoice.id).order(created_at: :desc).first&.sequential_id || 0 - - expect { retry_service.call } - .to change { invoice.reload.number } - .from("#{organization.document_number_prefix}-DRAFT") - .to("#{customer_slug}-#{format("%03d", sequential_id + 1)}") - end - - it 'generates expected invoice totals' do - result = retry_service.call - - aggregate_failures do - expect(result).to be_success - expect(result.invoice.fees.charge.count).to eq(1) - expect(result.invoice.fees.subscription.count).to eq(1) - - expect(result.invoice.currency).to eq('EUR') - expect(result.invoice.fees_amount_cents).to eq(3_000) - - expect(result.invoice.taxes_amount_cents).to eq(350) - expect(result.invoice.taxes_rate.round(2)).to eq(11.67) # (0.667 * 10) + (0.333 * 15) - expect(result.invoice.applied_taxes.count).to eq(2) - - expect(result.invoice.total_amount_cents).to eq(3_350) - end - end - - it_behaves_like 'syncs invoice' do - let(:service_call) { retry_service.call } - end - - it 'enqueues a SendWebhookJob' do - expect do - retry_service.call - end.to have_enqueued_job(SendWebhookJob).with('invoice.created', Invoice) - end - - it 'enqueues GeneratePdfAndNotifyJob with email false' do - expect do - retry_service.call - end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: false)) - end - - context 'with lago_premium' do - around { |test| lago_premium!(&test) } - - it 'enqueues GeneratePdfAndNotifyJob with email true' do - expect do - retry_service.call - end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: true)) - end - - context 'when organization does not have right email settings' do - before { invoice.organization.update!(email_settings: []) } - - it 'enqueues GeneratePdfAndNotifyJob with email false' do - expect do - retry_service.call - end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: false)) - end - end - end - - it 'calls SegmentTrackJob' do - invoice = retry_service.call.invoice - - expect(SegmentTrackJob).to have_received(:perform_later).with( - membership_id: CurrentContext.membership, - event: 'invoice_created', - properties: { - organization_id: invoice.organization.id, - invoice_id: invoice.id, - invoice_type: invoice.invoice_type - } - ) - end - - it 'creates a payment' do - allow(Invoices::Payments::CreateService).to receive(:call_async) - + it 'enqueues a Invoices::ProviderTaxes::PullTaxesAndApplyJob' do + expect do retry_service.call - expect(Invoices::Payments::CreateService).to have_received(:call_async) - end - - context 'with credit notes' do - let(:credit_note) do - create( - :credit_note, - customer:, - total_amount_cents: 10, - total_amount_currency: 'EUR', - balance_amount_cents: 10, - balance_amount_currency: 'EUR', - credit_amount_cents: 10, - credit_amount_currency: 'EUR' - ) - end - - before { credit_note } - - it 'updates the invoice accordingly' do - result = retry_service.call - - aggregate_failures do - expect(result).to be_success - expect(result.invoice.fees_amount_cents).to eq(3_000) - expect(result.invoice.taxes_amount_cents).to eq(350) - expect(result.invoice.total_amount_cents).to eq(3_340) - expect(result.invoice.credits.count).to eq(1) - - credit = result.invoice.credits.first - expect(credit.credit_note).to eq(credit_note) - expect(credit.amount_cents).to eq(10) - end - end - - context 'when invoice type is one_off' do - before do - invoice.update!(invoice_type: :one_off) - end - - it 'does not apply credit note' do - result = retry_service.call - - aggregate_failures do - expect(result).to be_success - expect(result.invoice.fees_amount_cents).to eq(3_000) - expect(result.invoice.taxes_amount_cents).to eq(350) - expect(result.invoice.total_amount_cents).to eq(3_350) - expect(result.invoice.credits.count).to eq(0) - end - end - end - end + end.to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) end - context 'when failed to fetch taxes' do - let(:body) do - path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') - File.read(path) - end - - it 'keeps invoice in failed status' do - result = retry_service.call + it 'sets correct statuses' do + retry_service.call - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::ValidationFailure) - expect(invoice.reload.status).to eq('failed') - end - - it 'resolves old tax error and creates new one' do - old_error_id = invoice.reload.error_details.last.id - retry_service.call - aggregate_failures do - expect(invoice.error_details.tax_error.last.id).not_to eql(old_error_id) - expect(invoice.error_details.tax_error.count).to be(1) - expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) - end - end - - context 'with api limit error' do - let(:body) do - p = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json') - File.read(p) - end - - it 'keeps invoice in failed status' do - result = retry_service.call - - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::ValidationFailure) - expect(invoice.reload.status).to eq('failed') - end - - it 'resolves old tax error and creates new one' do - old_error_id = invoice.reload.error_details.last.id - - retry_service.call - - aggregate_failures do - expect(invoice.error_details.tax_error.last.id).not_to eql(old_error_id) - expect(invoice.error_details.tax_error.count).to be(1) - expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) - expect(invoice.error_details.tax_error.order(created_at: :asc).last.details['tax_error']) - .to eq('validationError') - expect(invoice.error_details.tax_error.order(created_at: :asc).last.details['tax_error_message']) - .to eq("You've exceeded your API limit of 10 per second") - end - end - end + expect(invoice.reload.status).to eq('pending') + expect(invoice.reload.tax_status).to eq('pending') end end end diff --git a/spec/services/invoices/subscription_service_spec.rb b/spec/services/invoices/subscription_service_spec.rb index 22333b1cc09..9a53d022525 100644 --- a/spec/services/invoices/subscription_service_spec.rb +++ b/spec/services/invoices/subscription_service_spec.rb @@ -129,43 +129,12 @@ context "when there is tax provider integration" do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(: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/finalized_invoices" } - let(:body) do - p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.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 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_wrap_original do |m, *args| # rubocop:disable RSpec/AnyInstance - fee = m.receiver - if fee.charge_id == plan.charges.first.id - "charge_fee_id-12345" - elsif fee.subscription_id == subscription.id - "sub_fee_id-12345" - else - m.call(*args) - end - end end - it "creates an invoice" do + it "creates an invoice with pending status and without applied taxes" do result = invoice_service.call aggregate_failures do @@ -181,36 +150,12 @@ expect(result.invoice.currency).to eq("EUR") expect(result.invoice.fees_amount_cents).to eq(100) - expect(result.invoice.taxes_amount_cents).to eq(10) - expect(result.invoice.taxes_rate).to eq(10) - expect(result.invoice.applied_taxes.count).to eq(2) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.taxes_rate).to eq(0) + expect(result.invoice.applied_taxes.count).to eq(0) - expect(result.invoice.total_amount_cents).to eq(110) expect(result.invoice.version_number).to eq(4) - expect(result.invoice).to be_finalized - 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 "returns tax error" do - result = invoice_service.call - - aggregate_failures do - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::ValidationFailure) - expect(result.error.messages[:tax_error]).to eq(["taxDateTooFarInFuture"]) - - invoice = customer.invoices.order(created_at: :desc).first - - expect(invoice.status).to eq("failed") - expect(invoice.error_details.count).to eq(1) - expect(invoice.error_details.first.details["tax_error"]).to eq("taxDateTooFarInFuture") - end + expect(result.invoice).to be_pending end end end diff --git a/spec/services/lifetime_usages/recalculate_and_check_service_spec.rb b/spec/services/lifetime_usages/recalculate_and_check_service_spec.rb index 1d8771a06c8..a84c401d30c 100644 --- a/spec/services/lifetime_usages/recalculate_and_check_service_spec.rb +++ b/spec/services/lifetime_usages/recalculate_and_check_service_spec.rb @@ -68,13 +68,13 @@ def create_thresholds(subscription, amounts:, recurring: nil) end context 'when there is tax provider error' do - let(:error_result) { BaseService::Result.new.service_failure!(code: 'tax_error', message: '') } + let(:error_result) { BaseService::Result.new.unknown_tax_failure!(code: 'tax_error', message: '') } before do allow(Invoices::ProgressiveBillingService).to receive(:call).and_return(error_result) end - it "creates a failed invoice without raising error" do + it "creates a pending invoice without raising error" do expect { service.call }.not_to raise_error end end From 81bdf299c958785ec90ae8ac14ed004152ed7cf2 Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Mon, 23 Dec 2024 17:37:34 +0100 Subject: [PATCH 3/6] enable throttling for anrok --- app/graphql/types/invoices/object.rb | 1 + .../types/invoices/tax_status_type_enum.rb | 13 ++++++ app/jobs/fees/create_pay_in_advance_job.rb | 4 ++ .../create_pay_in_advance_charge_job.rb | 3 ++ .../pull_taxes_and_apply_job.rb | 4 ++ .../taxes/invoices/create_draft_service.rb | 2 + .../taxes/invoices/create_service.rb | 2 + schema.graphql | 7 ++++ schema.json | 41 +++++++++++++++++++ .../mutations/invoices/finalize_spec.rb | 32 +++++++++++++++ 10 files changed, 109 insertions(+) create mode 100644 app/graphql/types/invoices/tax_status_type_enum.rb diff --git a/app/graphql/types/invoices/object.rb b/app/graphql/types/invoices/object.rb index e5e6465572c..e9aee985dd9 100644 --- a/app/graphql/types/invoices/object.rb +++ b/app/graphql/types/invoices/object.rb @@ -19,6 +19,7 @@ class Object < Types::BaseObject field :payment_dispute_lost_at, GraphQL::Types::ISO8601DateTime field :payment_status, Types::Invoices::PaymentStatusTypeEnum, null: false field :status, Types::Invoices::StatusTypeEnum, null: false + field :tax_status, Types::Invoices::TaxStatusTypeEnum, null: true field :voidable, Boolean, null: false, method: :voidable? field :currency, Types::CurrencyEnum diff --git a/app/graphql/types/invoices/tax_status_type_enum.rb b/app/graphql/types/invoices/tax_status_type_enum.rb new file mode 100644 index 00000000000..dc37aa999ec --- /dev/null +++ b/app/graphql/types/invoices/tax_status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invoices + class TaxStatusTypeEnum < Types::BaseEnum + graphql_name 'InvoiceTaxStatusTypeEnum' + + Invoice::TAX_STATUSES.keys.each do |type| + value type + end + end + end +end diff --git a/app/jobs/fees/create_pay_in_advance_job.rb b/app/jobs/fees/create_pay_in_advance_job.rb index 3424c2380a7..0a4c0285c92 100644 --- a/app/jobs/fees/create_pay_in_advance_job.rb +++ b/app/jobs/fees/create_pay_in_advance_job.rb @@ -2,8 +2,12 @@ module Fees class CreatePayInAdvanceJob < ApplicationJob + include ConcurrencyThrottlable + queue_as :default + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + unique :until_executed, on_conflict: :log def perform(charge:, event:, billing_at: nil) diff --git a/app/jobs/invoices/create_pay_in_advance_charge_job.rb b/app/jobs/invoices/create_pay_in_advance_charge_job.rb index ac2b01521a2..1d56b70a384 100644 --- a/app/jobs/invoices/create_pay_in_advance_charge_job.rb +++ b/app/jobs/invoices/create_pay_in_advance_charge_job.rb @@ -2,6 +2,8 @@ module Invoices class CreatePayInAdvanceChargeJob < ApplicationJob + include ConcurrencyThrottlable + queue_as do if ActiveModel::Type::Boolean.new.cast(ENV['SIDEKIQ_BILLING']) :billing @@ -11,6 +13,7 @@ class CreatePayInAdvanceChargeJob < ApplicationJob end retry_on Sequenced::SequenceError + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 unique :until_executed, on_conflict: :log diff --git a/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb b/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb index 0153240fdc1..18f9a8ca245 100644 --- a/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb +++ b/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb @@ -3,8 +3,12 @@ module Invoices module ProviderTaxes class PullTaxesAndApplyJob < ApplicationJob + include ConcurrencyThrottlable + queue_as 'integrations' + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + def perform(invoice:) Invoices::ProviderTaxes::PullTaxesAndApplyService.call(invoice:) end diff --git a/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb index 6d7b924d5a7..05204808399 100644 --- a/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb +++ b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb @@ -13,6 +13,8 @@ def call return result unless integration return result unless integration.type == 'Integrations::AnrokIntegration' + throttle!(:anrok) + response = http_client.post_with_response(payload, headers) body = JSON.parse(response.body) diff --git a/app/services/integrations/aggregator/taxes/invoices/create_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_service.rb index 0f531882107..202d820366a 100644 --- a/app/services/integrations/aggregator/taxes/invoices/create_service.rb +++ b/app/services/integrations/aggregator/taxes/invoices/create_service.rb @@ -13,6 +13,8 @@ def call return result unless integration return result unless integration.type == 'Integrations::AnrokIntegration' + throttle!(:anrok) + response = http_client.post_with_response(payload, headers) body = JSON.parse(response.body) diff --git a/schema.graphql b/schema.graphql index 7332191b269..1774b9b191e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -4475,6 +4475,7 @@ type Invoice { subTotalIncludingTaxesAmountCents: BigInt! subscriptions: [Subscription!] taxProviderVoidable: Boolean! + taxStatus: InvoiceTaxStatusTypeEnum taxesAmountCents: BigInt! taxesRate: Float! totalAmountCents: BigInt! @@ -4621,6 +4622,12 @@ type InvoiceSubscription { totalAmountCents: BigInt! } +enum InvoiceTaxStatusTypeEnum { + failed + pending + succeeded +} + enum InvoiceTypeEnum { add_on advance_charges diff --git a/schema.json b/schema.json index c7895054840..43fe17fd48b 100644 --- a/schema.json +++ b/schema.json @@ -21064,6 +21064,18 @@ "deprecationReason": null, "args": [] }, + { + "name": "taxStatus", + "description": null, + "type": { + "kind": "ENUM", + "name": "InvoiceTaxStatusTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, { "name": "taxesAmountCents", "description": null, @@ -22240,6 +22252,35 @@ "inputFields": null, "enumValues": null }, + { + "kind": "ENUM", + "name": "InvoiceTaxStatusTypeEnum", + "description": null, + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "kind": "ENUM", "name": "InvoiceTypeEnum", diff --git a/spec/graphql/mutations/invoices/finalize_spec.rb b/spec/graphql/mutations/invoices/finalize_spec.rb index 97884e91552..1b14373f7cf 100644 --- a/spec/graphql/mutations/invoices/finalize_spec.rb +++ b/spec/graphql/mutations/invoices/finalize_spec.rb @@ -15,6 +15,7 @@ finalizeInvoice(input: $input) { id status + taxStatus } } GQL @@ -44,4 +45,35 @@ end end end + + context 'with tax provider' do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + it 'returns pending invoice' do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result['data']['finalizeInvoice'] + + aggregate_failures do + expect(result_data['id']).to be_present + expect(result_data['status']).to eq('pending') + expect(result_data['taxStatus']).to eq('pending') + end + end + end + end end From 02294afafdaf1b95b5ab3563fd9a282f87a1d044 Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Tue, 24 Dec 2024 14:52:26 +0100 Subject: [PATCH 4/6] fix linter --- app/services/invoices/refresh_draft_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/invoices/refresh_draft_service.rb b/app/services/invoices/refresh_draft_service.rb index 1fb900423cb..73f5cb564ea 100644 --- a/app/services/invoices/refresh_draft_service.rb +++ b/app/services/invoices/refresh_draft_service.rb @@ -109,7 +109,7 @@ def flag_lifetime_usage_for_refresh end def tax_error?(error) - error && error.is_a?(BaseService::UnknownTaxFailure) + error&.is_a?(BaseService::UnknownTaxFailure) end def reset_invoice_values From faa2e0710d58e5ae6e764be2212046d01e0d521d Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Fri, 27 Dec 2024 12:54:03 +0100 Subject: [PATCH 5/6] add small adjustment --- .../pull_taxes_and_apply_service.rb | 19 ++++++++++++++----- .../pull_taxes_and_apply_service_spec.rb | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb b/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb index ae81572f7d9..1a64109dcda 100644 --- a/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb +++ b/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb @@ -34,6 +34,11 @@ def call provider_taxes = taxes_result.fees ActiveRecord::Base.transaction do + unless invoice.draft? + invoice.issuing_date = issuing_date + invoice.payment_due_date = payment_due_date + end + Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes:) create_credit_note_credit if should_create_credit_note_credit? @@ -41,11 +46,7 @@ def call invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded invoice.tax_status = 'succeeded' - if invoice.draft? - invoice.status = :draft - else - Invoices::TransitionToFinalStatusService.call(invoice:) - end + Invoices::TransitionToFinalStatusService.call(invoice:) unless invoice.draft? invoice.save! invoice.reload @@ -115,6 +116,14 @@ def create_applied_prepaid_credit invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents end + def issuing_date + @issuing_date ||= Time.current.in_time_zone(customer.applicable_timezone).to_date + end + + def payment_due_date + @payment_due_date ||= issuing_date + customer.applicable_net_payment_term.days + end + def customer @customer ||= invoice.customer end diff --git a/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb b/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb index 38a52dcada5..da666255113 100644 --- a/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb +++ b/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb @@ -146,6 +146,18 @@ .to change(invoice.error_details.tax_error, :count).from(1).to(0) end + it 'updates the issuing date and payment due date' do + invoice.customer.update(timezone: 'America/New_York') + + freeze_time do + current_date = Time.current.in_time_zone('America/New_York').to_date + + expect { pull_taxes_service.call } + .to change { invoice.reload.issuing_date }.to(current_date) + .and change { invoice.reload.payment_due_date }.to(current_date) + end + end + it 'generates invoice number' do customer_slug = "#{organization.document_number_prefix}-#{format("%03d", customer.sequential_id)}" sequential_id = customer.invoices.where.not(id: invoice.id).order(created_at: :desc).first&.sequential_id || 0 From 3a315d41fd2c236fb3dafccad2481e222875f9cf Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Fri, 27 Dec 2024 17:29:59 +0100 Subject: [PATCH 6/6] fix based on PR comments --- .../refresh_draft_and_finalize_service.rb | 6 -- .../mutations/invoices/finalize_spec.rb | 58 +++++++++---------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/app/services/invoices/refresh_draft_and_finalize_service.rb b/app/services/invoices/refresh_draft_and_finalize_service.rb index 02c2435b63d..98b562883b6 100644 --- a/app/services/invoices/refresh_draft_and_finalize_service.rb +++ b/app/services/invoices/refresh_draft_and_finalize_service.rb @@ -78,11 +78,5 @@ def should_deliver_email? License.premium? && invoice.organization.email_settings.include?('invoice.finalized') end - - def tax_error?(error) - return false unless error.is_a?(BaseService::ValidationFailure) - - error&.messages&.dig(:tax_error).present? - end end end diff --git a/spec/graphql/mutations/invoices/finalize_spec.rb b/spec/graphql/mutations/invoices/finalize_spec.rb index 1b14373f7cf..5174c8ee00c 100644 --- a/spec/graphql/mutations/invoices/finalize_spec.rb +++ b/spec/graphql/mutations/invoices/finalize_spec.rb @@ -26,23 +26,21 @@ it_behaves_like 'requires permission', 'invoices:update' it 'finalizes the given invoice' do - freeze_time do - result = execute_graphql( - current_user: membership.user, - current_organization: organization, - permissions: required_permission, - query: mutation, - variables: { - input: {id: invoice.id} - } - ) + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) - result_data = result['data']['finalizeInvoice'] + result_data = result['data']['finalizeInvoice'] - aggregate_failures do - expect(result_data['id']).to be_present - expect(result_data['status']).to eq('finalized') - end + aggregate_failures do + expect(result_data['id']).to be_present + expect(result_data['status']).to eq('finalized') end end @@ -55,25 +53,21 @@ end it 'returns pending invoice' do - freeze_time do - result = execute_graphql( - current_user: membership.user, - current_organization: organization, - permissions: required_permission, - query: mutation, - variables: { - input: {id: invoice.id} - } - ) + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) - result_data = result['data']['finalizeInvoice'] + result_data = result['data']['finalizeInvoice'] - aggregate_failures do - expect(result_data['id']).to be_present - expect(result_data['status']).to eq('pending') - expect(result_data['taxStatus']).to eq('pending') - end - end + expect(result_data['id']).to be_present + expect(result_data['status']).to eq('pending') + expect(result_data['taxStatus']).to eq('pending') end end end