diff --git a/app/graphql/types/invoices/object.rb b/app/graphql/types/invoices/object.rb index 01c5b58b958..20ba71f0fb2 100644 --- a/app/graphql/types/invoices/object.rb +++ b/app/graphql/types/invoices/object.rb @@ -78,6 +78,10 @@ def external_integration_id resource_type: :invoice )&.external_id end + + def error_details + object.error_details.kept + end end end end diff --git a/app/serializers/v1/invoice_serializer.rb b/app/serializers/v1/invoice_serializer.rb index eb1b4485c57..4676ce67a1e 100644 --- a/app/serializers/v1/invoice_serializer.rb +++ b/app/serializers/v1/invoice_serializer.rb @@ -92,7 +92,7 @@ def applied_taxes def error_details ::CollectionSerializer.new( - model.error_details, + model.error_details.kept, ::V1::Invoices::ErrorDetailSerializer, collection_name: 'error_details' ).serialize diff --git a/app/services/invoices/retry_service.rb b/app/services/invoices/retry_service.rb index cc07334ad1d..c4ebec8230c 100644 --- a/app/services/invoices/retry_service.rb +++ b/app/services/invoices/retry_service.rb @@ -12,9 +12,11 @@ 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.kept.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:, fees: invoice.fees) unless taxes_result.success? + create_error_detail(taxes_result.error.code) return result.validation_failure!(errors: {tax_error: [taxes_result.error.code]}) end @@ -116,5 +118,19 @@ def payment_due_date def customer @customer ||= invoice.customer end + + def create_error_detail(code) + error_result = ErrorDetails::CreateService.call( + owner: invoice, + organization: invoice.organization, + params: { + error_code: :tax_error, + details: { + tax_error: code + } + } + ) + error_result.raise_if_error! + end end end diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb index ea6f4edaaad..3e58a782e78 100644 --- a/spec/factories/invoices.rb +++ b/spec/factories/invoices.rb @@ -24,9 +24,9 @@ payment_dispute_lost_at { DateTime.current - 1.day } end - trait :with_error do + trait :with_tax_error do after :create do |i| - create(:error_detail, owner: i) + create(:error_detail, owner: i, error_code: 'tax_error') end end diff --git a/spec/services/invoices/retry_service_spec.rb b/spec/services/invoices/retry_service_spec.rb index 6d30ee909e7..dca3856e2f7 100644 --- a/spec/services/invoices/retry_service_spec.rb +++ b/spec/services/invoices/retry_service_spec.rb @@ -13,6 +13,7 @@ create( :invoice, :failed, + :with_tax_error, customer:, organization:, subscriptions: [subscription], @@ -120,164 +121,194 @@ end end - it 'marks the invoice as finalized' do - expect { retry_service.call } - .to change(invoice, :status).from('failed').to('finalized') - 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 + 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.reload.issuing_date }.to(current_date) - .and change { invoice.reload.payment_due_date }.to(current_date) + .to change(invoice.error_details.tax_error.kept, :count).from(1).to(0) 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 + it 'updates the issuing date and payment due date' do + invoice.customer.update(timezone: 'America/New_York') - expect { retry_service.call } - .to change { invoice.reload.number } - .from("#{organization.document_number_prefix}-DRAFT") - .to("#{customer_slug}-#{format("%03d", sequential_id + 1)}") - end + freeze_time do + current_date = Time.current.in_time_zone('America/New_York').to_date - it 'generates expected invoice totals' do - result = retry_service.call + 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 - aggregate_failures do - expect(result).to be_success - expect(result.invoice.fees.charge_kind.count).to eq(1) - expect(result.invoice.fees.subscription_kind.count).to eq(1) + 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(result.invoice.currency).to eq('EUR') - expect(result.invoice.fees_amount_cents).to eq(3_000) + expect { retry_service.call } + .to change { invoice.reload.number } + .from("#{organization.document_number_prefix}-DRAFT") + .to("#{customer_slug}-#{format("%03d", sequential_id + 1)}") + end - 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) + it 'generates expected invoice totals' do + result = retry_service.call - expect(result.invoice.total_amount_cents).to eq(3_350) - end - end + aggregate_failures do + expect(result).to be_success + expect(result.invoice.fees.charge_kind.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) - it_behaves_like 'syncs invoice' do - let(:service_call) { retry_service.call } - end + expect(result.invoice.currency).to eq('EUR') + expect(result.invoice.fees_amount_cents).to eq(3_000) - it_behaves_like 'syncs sales order' do - let(:service_call) { retry_service.call } - end + 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) - it 'enqueues a SendWebhookJob' do - expect do - retry_service.call - end.to have_enqueued_job(SendWebhookJob).with('invoice.created', Invoice) - end + expect(result.invoice.total_amount_cents).to eq(3_350) + end + 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 + it_behaves_like 'syncs invoice' do + let(:service_call) { retry_service.call } + end - context 'with lago_premium' do - around { |test| lago_premium!(&test) } + it_behaves_like 'syncs sales order' 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 true' do + it 'enqueues GeneratePdfAndNotifyJob with email false' do expect do retry_service.call - end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: true)) + end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: false)) end - context 'when organization does not have right email settings' do - before { invoice.organization.update!(email_settings: []) } + context 'with lago_premium' do + around { |test| lago_premium!(&test) } - it 'enqueues GeneratePdfAndNotifyJob with email false' do + it 'enqueues GeneratePdfAndNotifyJob with email true' do expect do retry_service.call - end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: false)) + end.to have_enqueued_job(Invoices::GeneratePdfAndNotifyJob).with(hash_including(email: true)) 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 - payment_create_service = instance_double(Invoices::Payments::CreateService) - allow(Invoices::Payments::CreateService).to receive(:new).and_return(payment_create_service) - allow(payment_create_service).to receive(:call) + context 'when organization does not have right email settings' do + before { invoice.organization.update!(email_settings: []) } - retry_service.call - expect(Invoices::Payments::CreateService).to have_received(:new) - expect(payment_create_service).to have_received(:call) - 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 + end + 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' + 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 - 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) + it 'creates a payment' do + payment_create_service = instance_double(Invoices::Payments::CreateService) + allow(Invoices::Payments::CreateService).to receive(:new).and_return(payment_create_service) + allow(payment_create_service).to receive(:call) - credit = result.invoice.credits.first - expect(credit.credit_note).to eq(credit_note) - expect(credit.amount_cents).to eq(10) - end + retry_service.call + expect(Invoices::Payments::CreateService).to have_received(:new) + expect(payment_create_service).to have_received(:call) end - context 'when invoice type is one_off' do - before do - invoice.update!(invoice_type: :one_off) + 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 - it 'does not apply credit note' do + 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_350) - expect(result.invoice.credits.count).to eq(0) + 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 + + 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 + + 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 + aggregate_failures do + expect { retry_service.call }.to change(invoice.error_details.tax_error, :count).from(1).to(2) + expect(invoice.error_details.tax_error.kept.count).to be(1) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) + end end end end