diff --git a/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb b/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb new file mode 100644 index 00000000000..6ee8ee48741 --- /dev/null +++ b/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class RetryTaxProviderVoiding < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = 'invoices:update' + + description 'Retry voided invoice sync' + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + result = ::Invoices::ProviderTaxes::VoidService.call(invoice:) + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/types/invoices/object.rb b/app/graphql/types/invoices/object.rb index c33402b18b5..bb32321ca84 100644 --- a/app/graphql/types/invoices/object.rb +++ b/app/graphql/types/invoices/object.rb @@ -56,6 +56,7 @@ class Object < Types::BaseObject field :external_integration_id, String, null: true field :integration_syncable, GraphQL::Types::Boolean, null: false + field :tax_provider_voidable, GraphQL::Types::Boolean, null: false delegate :error_details, to: :object @@ -68,6 +69,12 @@ def integration_syncable object.integration_resources.where(resource_type: 'invoice', syncable_type: 'Invoice').none? end + def tax_provider_voidable + return false unless object.voided? + + object.error_details.tax_voiding_error.any? + end + def external_integration_id integration_customer = object.customer&.integration_customers&.accounting_kind&.first diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 4b089f21bd3..7ce3a86a52b 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -89,6 +89,7 @@ class MutationType < Types::BaseObject field :retry_all_invoices, mutation: Mutations::Invoices::RetryAll field :retry_invoice, mutation: Mutations::Invoices::Retry field :retry_invoice_payment, mutation: Mutations::Invoices::RetryPayment + field :retry_tax_provider_voiding, mutation: Mutations::Invoices::RetryTaxProviderVoiding field :update_invoice, mutation: Mutations::Invoices::Update field :void_invoice, mutation: Mutations::Invoices::Void diff --git a/app/jobs/invoices/provider_taxes/void_job.rb b/app/jobs/invoices/provider_taxes/void_job.rb new file mode 100644 index 00000000000..062660bbcac --- /dev/null +++ b/app/jobs/invoices/provider_taxes/void_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class VoidJob < ApplicationJob + queue_as 'integrations' + + def perform(invoice:) + return unless invoice.customer.anrok_customer + + # NOTE: We don't want to raise error here. + # If sync fails, invoice would be marked and retry option would be available in the UI + Invoices::ProviderTaxes::VoidService.call(invoice:) + end + end + end +end diff --git a/app/models/error_detail.rb b/app/models/error_detail.rb index e468a57eb45..d0565d2b8c3 100644 --- a/app/models/error_detail.rb +++ b/app/models/error_detail.rb @@ -8,6 +8,6 @@ class ErrorDetail < ApplicationRecord belongs_to :owner, polymorphic: true belongs_to :organization - ERROR_CODES = %w[not_provided tax_error] + ERROR_CODES = %w[not_provided tax_error tax_voiding_error] enum error_code: ERROR_CODES end diff --git a/app/services/invoices/lose_dispute_service.rb b/app/services/invoices/lose_dispute_service.rb index 2e22d3f0ca3..28f9e9c93f8 100644 --- a/app/services/invoices/lose_dispute_service.rb +++ b/app/services/invoices/lose_dispute_service.rb @@ -17,6 +17,7 @@ def call invoice.mark_as_dispute_lost!(payment_dispute_lost_at) SendWebhookJob.perform_later('invoice.payment_dispute_lost', result.invoice, provider_error: reason) + Invoices::ProviderTaxes::VoidJob.perform_later(invoice:) result rescue ActiveRecord::RecordInvalid => _e diff --git a/app/services/invoices/provider_taxes/void_service.rb b/app/services/invoices/provider_taxes/void_service.rb new file mode 100644 index 00000000000..4e1cb438e11 --- /dev/null +++ b/app/services/invoices/provider_taxes/void_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class VoidService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: 'invoice') if invoice.blank? + + invoice.error_details.tax_voiding_error.discard_all + + tax_result = Integrations::Aggregator::Taxes::Invoices::VoidService.new(invoice:).call + + if frozen_transaction?(tax_result) + negate_result = perform_invoice_negate + + unless negate_result.success? + return result.validation_failure!(errors: {tax_error: [negate_result.error.code]}) + end + elsif !tax_result.success? + create_error_detail(tax_result.error.code) + + return result.validation_failure!(errors: {tax_error: [tax_result.error.code]}) + end + + result.invoice = invoice + + result + end + + private + + attr_reader :invoice + + delegate :customer, to: :invoice + + def perform_invoice_negate + negate_result = Integrations::Aggregator::Taxes::Invoices::NegateService.new(invoice:).call + + create_error_detail(negate_result.error.code) unless negate_result.success? + + negate_result + end + + def create_error_detail(code) + error_result = ErrorDetails::CreateService.call( + owner: invoice, + organization: invoice.organization, + params: { + error_code: :tax_voiding_error, + details: { + tax_voiding_error: code + } + } + ) + error_result.raise_if_error! + end + + # transactionFrozenForFiling error means that tax is already reported to the tax authority + # We should call negate action instead + def frozen_transaction?(tax_result) + !tax_result.success? && tax_result.error.code == 'transactionFrozenForFiling' + end + end + end +end diff --git a/app/services/invoices/void_service.rb b/app/services/invoices/void_service.rb index 442aaa185e1..47809951d66 100644 --- a/app/services/invoices/void_service.rb +++ b/app/services/invoices/void_service.rb @@ -33,6 +33,7 @@ def call end SendWebhookJob.perform_later('invoice.voided', result.invoice) + Invoices::ProviderTaxes::VoidJob.perform_later(invoice:) result rescue AASM::InvalidTransition => _e diff --git a/schema.graphql b/schema.graphql index 4dc359a1381..fade9141949 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3453,6 +3453,7 @@ enum EmailSettingsEnum { enum ErrorCodesEnum { not_provided tax_error + tax_voiding_error } type ErrorDetail { @@ -3904,6 +3905,7 @@ type Invoice { subTotalExcludingTaxesAmountCents: BigInt! subTotalIncludingTaxesAmountCents: BigInt! subscriptions: [Subscription!] + taxProviderVoidable: Boolean! taxesAmountCents: BigInt! taxesRate: Float! totalAmountCents: BigInt! @@ -4761,6 +4763,16 @@ type Mutation { input: RetryInvoicePaymentInput! ): Invoice + """ + Retry voided invoice sync + """ + retryTaxProviderVoiding( + """ + Parameters for RetryTaxProviderVoiding + """ + input: RetryTaxProviderVoidingInput! + ): Invoice + """ Retry a Webhook """ @@ -5979,6 +5991,17 @@ input RetryInvoicePaymentInput { id: ID! } +""" +Autogenerated input type of RetryTaxProviderVoiding +""" +input RetryTaxProviderVoidingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + """ Autogenerated input type of RetryWebhook """ diff --git a/schema.json b/schema.json index 67f6eebbedb..47f9737aced 100644 --- a/schema.json +++ b/schema.json @@ -14686,6 +14686,12 @@ "description": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tax_voiding_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null } ] }, @@ -19058,6 +19064,24 @@ ] }, + { + "name": "taxProviderVoidable", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "taxesAmountCents", "description": null, @@ -23047,6 +23071,35 @@ } ] }, + { + "name": "retryTaxProviderVoiding", + "description": "Retry voided invoice sync", + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for RetryTaxProviderVoiding", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryTaxProviderVoidingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "retryWebhook", "description": "Retry a Webhook", @@ -31384,6 +31437,45 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "RetryTaxProviderVoidingInput", + "description": "Autogenerated input type of RetryTaxProviderVoiding", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "RetryWebhookInput", diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb index ead60c40d95..e410b584ca8 100644 --- a/spec/factories/invoices.rb +++ b/spec/factories/invoices.rb @@ -30,6 +30,12 @@ end end + trait :with_tax_voiding_error do + after :create do |i| + create(:error_detail, owner: i, error_code: 'tax_voiding_error') + end + end + trait :failed do status { :failed } end diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json new file mode 100644 index 00000000000..bc81e483d88 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json @@ -0,0 +1,11 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "id": "invoice_id", + "validation_errors": { + "type": "transactionFrozenForFiling" + } + } + ] +} diff --git a/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb b/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb new file mode 100644 index 00000000000..9590182cd29 --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Invoices::RetryTaxProviderVoiding, type: :graphql do + let(:required_permission) { 'invoices:update' } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, payment_provider: 'gocardless') } + let(:user) { membership.user } + + let(:invoice) do + create( + :invoice, + :voided, + :with_tax_voiding_error, + organization:, + customer:, + subscriptions: [subscription], + currency: 'EUR' + ) + 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(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + 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/void_invoices' } + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json') + File.read(path) + 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: RetryTaxProviderVoidingInput!) { + retryTaxProviderVoiding(input: $input) { + id + status + } + } + GQL + 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' + it_behaves_like 'requires current organization' + it_behaves_like 'requires permission', 'invoices:update' + + context 'with valid preconditions' do + it 'returns the invoice after successful retry' do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + data = result['data']['retryTaxProviderVoiding'] + + expect(data['id']).to eq(invoice.id) + expect(data['status']).to eq('voided') + end + end +end diff --git a/spec/jobs/invoices/provider_taxes/void_job_spec.rb b/spec/jobs/invoices/provider_taxes/void_job_spec.rb new file mode 100644 index 00000000000..1b8f10392d9 --- /dev/null +++ b/spec/jobs/invoices/provider_taxes/void_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Invoices::ProviderTaxes::VoidJob, type: :job do + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ProviderTaxes::VoidService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context 'when there is anrok customer' do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before { integration_customer } + + it 'calls successfully void service' do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::VoidService).to have_received(:call) + end + end + + context 'when there is NOT anrok customer' do + it 'does not call void service' do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::VoidService).not_to have_received(:call) + end + end +end diff --git a/spec/services/invoices/lose_dispute_service_spec.rb b/spec/services/invoices/lose_dispute_service_spec.rb index 793f619ae1c..e0b0378e3ec 100644 --- a/spec/services/invoices/lose_dispute_service_spec.rb +++ b/spec/services/invoices/lose_dispute_service_spec.rb @@ -81,6 +81,12 @@ lose_dispute_service.call end.to have_enqueued_job(SendWebhookJob).with('invoice.payment_dispute_lost', invoice, provider_error: nil) end + + it 'enqueues a sync void invoice job' do + expect do + lose_dispute_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice:) + end end end end diff --git a/spec/services/invoices/provider_taxes/void_service_spec.rb b/spec/services/invoices/provider_taxes/void_service_spec.rb new file mode 100644 index 00000000000..4ec56624a20 --- /dev/null +++ b/spec/services/invoices/provider_taxes/void_service_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Invoices::ProviderTaxes::VoidService, type: :service do + subject(:void_service) { described_class.new(invoice:) } + + describe '#call' do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :voided, + :with_tax_voiding_error, + 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 + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response1) { instance_double(Net::HTTPOK) } + let(:lago_client1) { instance_double(LagoHttpClient::Client) } + let(:response2) { instance_double(Net::HTTPOK) } + let(:lago_client2) { instance_double(LagoHttpClient::Client) } + let(:void_endpoint) { 'https://api.nango.dev/v1/anrok/void_invoices' } + let(:negate_endpoint) { 'https://api.nango.dev/v1/anrok/negate_invoices' } + let(:body_void) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json') + File.read(path) + end + let(:body_negate) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json') + File.read(path) + 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 + integration_customer + + allow(LagoHttpClient::Client).to receive(:new).with(void_endpoint).and_return(lago_client1) + allow(lago_client1).to receive(:post_with_response).and_return(response1) + allow(response1).to receive(:body).and_return(body_void) + + allow(LagoHttpClient::Client).to receive(:new).with(negate_endpoint).and_return(lago_client2) + allow(lago_client2).to receive(:post_with_response).and_return(response2) + allow(response2).to receive(:body).and_return(body_negate) + end + + context 'when invoice does not exist' do + it 'returns an error' do + result = described_class.new(invoice: nil).call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error.error_code).to eq('invoice_not_found') + end + end + end + + context 'when voided invoice is successfully synced' do + it 'returns successful result' do + result = void_service.call + + aggregate_failures do + expect(result).to be_success + expect(result.invoice.id).to eq(invoice.id) + end + end + + it 'discards previous tax errors' do + expect { void_service.call } + .to change(invoice.error_details.tax_voiding_error, :count).from(1).to(0) + end + end + + context 'when failed result is returned from void endpoint' do + let(:body_void) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') + File.read(path) + end + + it 'keeps invoice in voided status' do + result = void_service.call + + aggregate_failures do + expect(result).not_to be_success + expect(LagoHttpClient::Client).to have_received(:new).with(void_endpoint) + expect(LagoHttpClient::Client).not_to have_received(:new).with(negate_endpoint) + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(invoice.reload.status).to eq('voided') + end + end + + it 'resolves old tax error and creates new one' do + old_error_id = invoice.reload.error_details.last.id + + void_service.call + + aggregate_failures do + expect(invoice.error_details.tax_voiding_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_voiding_error.count).to be(1) + expect(invoice.error_details.tax_voiding_error.order(created_at: :asc).last.discarded?).to be(false) + end + end + end + + context 'when failed result is returned from negate endpoint' do + let(:body_void) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json') + File.read(path) + end + let(:body_negate) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') + File.read(path) + end + + it 'keeps invoice in voided status' do + result = void_service.call + + aggregate_failures do + expect(result).not_to be_success + expect(LagoHttpClient::Client).to have_received(:new).with(void_endpoint) + expect(LagoHttpClient::Client).to have_received(:new).with(negate_endpoint) + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(invoice.reload.status).to eq('voided') + end + end + + it 'resolves old tax error and creates new one' do + old_error_id = invoice.reload.error_details.last.id + + void_service.call + + aggregate_failures do + expect(invoice.error_details.tax_voiding_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_voiding_error.count).to be(1) + expect(invoice.error_details.tax_voiding_error.order(created_at: :asc).last.discarded?).to be(false) + end + end + end + end +end diff --git a/spec/services/invoices/void_service_spec.rb b/spec/services/invoices/void_service_spec.rb index 110f3866e01..866c98853e7 100644 --- a/spec/services/invoices/void_service_spec.rb +++ b/spec/services/invoices/void_service_spec.rb @@ -81,6 +81,12 @@ end end + it 'enqueues a sync void invoice job' do + expect do + void_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice:) + end + it "marks the invoice's payment overdue as false" do expect { void_service.call }.to change(invoice, :payment_overdue).from(true).to(false) end