Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(anrok): add handling discarded errors when retrying invoice #2341

Merged
merged 6 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/graphql/types/invoices/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app/serializers/v1/invoice_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions app/services/invoices/retry_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions spec/factories/invoices.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
261 changes: 146 additions & 115 deletions spec/services/invoices/retry_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
create(
:invoice,
:failed,
:with_tax_error,
customer:,
organization:,
subscriptions: [subscription],
Expand Down Expand Up @@ -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
Expand Down