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(ProgressiveBilling) - automated CreditNote generation at the periodic invoice if needed #2475

Merged
merged 5 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ end
group :development, :test do
gem 'byebug'
gem 'clockwork-test'
gem 'debug', platforms: %i[mri mingw x64_mingw]
gem 'debug', platforms: %i[mri mingw x64_mingw], require: false
gem 'dotenv'
gem 'i18n-tasks', git: 'https://github.com/glebm/i18n-tasks.git'
gem 'rspec-rails'
Expand Down
20 changes: 13 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ GEM
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.4)
debug (1.6.3)
irb (>= 1.3.6)
reline (>= 0.3.1)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
diff-lcs (1.5.0)
digest-crc (0.6.4)
Expand Down Expand Up @@ -295,9 +295,10 @@ GEM
httpclient (2.8.3)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
io-console (0.5.11)
irb (1.4.1)
reline (>= 0.3.0)
io-console (0.7.2)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.1)
json (2.7.1)
jwt (2.7.0)
Expand Down Expand Up @@ -607,6 +608,8 @@ GEM
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
psych (5.1.2)
stringio
public_suffix (5.0.1)
puma (6.4.2)
nio4r (~> 2.0)
Expand Down Expand Up @@ -662,14 +665,16 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rdoc (6.7.0)
psych (>= 4.0.0)
redis (5.1.0)
redis-client (>= 0.17.0)
redis-client (0.17.0)
connection_pool
redlock (2.0.6)
redis-client (>= 0.14.1, < 1.0.0)
regexp_parser (2.9.0)
reline (0.3.1)
reline (0.5.9)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
Expand Down Expand Up @@ -826,6 +831,7 @@ GEM
standard-performance (1.3.1)
lint_roller (~> 1.1)
rubocop-performance (~> 1.20.2)
stringio (3.1.1)
stripe (6.5.0)
strong_migrations (2.0.0)
activerecord (>= 6.1)
Expand Down
2 changes: 2 additions & 0 deletions app/models/invoice.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

class Invoice < ApplicationRecord
self.ignored_columns += [:negative_amount_cents] # TODO: remove when negative_amount_cents is removed from the database

include AASM
include PaperTrailTraceable
include Sequenced
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module CreditNotes
class CreateFromProgressiveBillingInvoice < BaseService
def initialize(progressive_billing_invoice:, amount:, reason: :other)
@progressive_billing_invoice = progressive_billing_invoice
@amount = amount
@reason = reason

super
end

def call
return result unless amount.positive?
return result.forbidden_failure! unless progressive_billing_invoice.progressive_billing?

# Important to call this method as it modifies @amount if needed
items = calculate_items!

CreditNotes::CreateService.new(
invoice: progressive_billing_invoice,
credit_amount_cents: amount,
vincent-pochet marked this conversation as resolved.
Show resolved Hide resolved
items:,
reason:,
automatic: true
).call.raise_if_error!
end

private

attr_reader :progressive_billing_invoice, :amount, :reason

def calculate_items!
items = []
remaining = amount

# The amount can be greater than a single fee amount. We'll keep on deducting until we've credited enough
progressive_billing_invoice.fees.order(amount_cents: :desc).each do |fee|
# no further credit remaining
break if remaining.zero?

# take the lower value of remaining or maximum creditable for this fee. (whichever is the lowest)
fee_credit_amount = [remaining, fee.creditable_amount_cents].min
items << {
fee_id: fee.id,
amount_cents: fee_credit_amount.truncate(CreditNote::DB_PRECISION_SCALE)
}

remaining -= fee_credit_amount
end

# it could be that we have some amount remaining
# TODO(ProgressiveBilling): verify and check in v2
if remaining.positive?
@amount -= remaining
end

items
end
end
end
6 changes: 4 additions & 2 deletions app/services/credits/progressive_billing_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ def call
amount_to_credit = progressive_billing_invoice.fees_amount_cents

if amount_to_credit > remaining_to_credit
# TODO: create credit note for (amount_to_credit - remaining_credit)
invoice.negative_amount_cents -= (amount_to_credit - remaining_to_credit)
CreditNotes::CreateFromProgressiveBillingInvoice.call(
progressive_billing_invoice:, amount: amount_to_credit - remaining_to_credit
).raise_if_error!

amount_to_credit = remaining_to_credit
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe CreditNotes::CreateFromProgressiveBillingInvoice, type: :service do
subject(:credit_service) { described_class.new(progressive_billing_invoice:, amount:, reason:) }

let(:reason) { :other }
let(:amount) { 0 }
let(:invoice_type) { :progressive_billing }
let(:customer) { create(:customer) }
let(:organization) { customer.organization }

let(:progressive_billing_invoice) do
create(
:invoice,
customer:,
organization:,
currency: 'EUR',
fees_amount_cents: 120,
total_amount_cents: 120,
invoice_type:
)
end

let(:fee1) do
create(
:fee,
invoice: progressive_billing_invoice,
amount_cents: 80
)
end

let(:fee2) do
create(
:fee,
invoice: progressive_billing_invoice,
amount_cents: 40
)
end

before do
progressive_billing_invoice
fee1
fee2
end

describe "#call" do
it "does nothing when amount is zero" do
expect { credit_service.call }.not_to change(CreditNote, :count)
end

context "with amount greater than zero" do
let(:amount) { 100 }

context 'when called with a subscription invoice' do
let(:invoice_type) { :subscription }

it "fails when the passed in invoice is not a progressive billing invoice" do
result = credit_service.call
expect(result).not_to be_success
end
end

it "creates a credit note for all required fees" do
result = credit_service.call
credit_note = result.credit_note

expect(credit_note.credit_amount_cents).to eq(amount)
expect(credit_note.items.size).to eq(2)

credit_fee1 = credit_note.items.find { |i| i.fee == fee1 }
expect(credit_fee1.amount_cents).to eq(80)
credit_fee2 = credit_note.items.find { |i| i.fee == fee2 }
expect(credit_fee2.amount_cents).to eq(20)
end
end
end
end
17 changes: 15 additions & 2 deletions spec/services/credits/progressive_billing_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@
end

describe "#call" do
it "applies one credit to the invoice" do
it "applies all the credits to the invoice" do
result = credit_service.call
expect(result.credits.size).to eq(2)
first_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice }
Expand All @@ -185,7 +185,20 @@
expect(first_credit.amount_cents).to eq(980)

expect(invoice.progressive_billing_credit_amount_cents).to eq(1000)
expect(invoice.negative_amount_cents).to eq(-220)
end

it "creates credit notes for the remainder of the progressive billed invoices" do
expect { credit_service.call }.to change(CreditNote, :count).by(2)
# we were able to credit 1000 from the invoice, this means we've got 20 and 200 remaining respectively
aggregate_failures do
expect(progressive_billing_invoice2.credit_notes.size).to eq(1)
expect(progressive_billing_invoice3.credit_notes.size).to eq(1)

first = progressive_billing_invoice2.credit_notes.sole
expect(first.credit_amount_cents).to eq(20)
last = progressive_billing_invoice3.credit_notes.sole
expect(last.credit_amount_cents).to eq(200)
end
end
end
end
Expand Down
28 changes: 28 additions & 0 deletions spec/services/invoices/calculate_fees_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,34 @@
expect(Credits::ProgressiveBillingService).to have_received(:call).with(invoice:)
end

context "when a progressive_billing invoice is present" do
let(:progressive_invoice) do
create(:invoice,
customer:,
status: 'finalized',
invoice_type: :progressive_billing,
subscriptions: [subscription],
fees_amount_cents: 50,
issuing_date: timestamp - 5.days)
end

let(:progressive_fee) do
create(:charge_fee, amount_cents: 50, invoice: progressive_invoice)
end

before do
progressive_invoice
progressive_fee
end

it "creates a credit note for the amount that was billed too much" do
expect { invoice_service.call }.to change(CreditNote, :count).by(1)

credit_note = progressive_invoice.reload.credit_notes.sole
expect(credit_note.credit_amount_cents).to eq(50)
end
end

context 'when charge is pay_in_advance, not recurring and invoiceable' do
let(:charge) do
create(
Expand Down
7 changes: 7 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

require 'webmock/rspec'

# Allow remote debugging when RUBY_DEBUG_PORT is set
if ENV['RUBY_DEBUG_PORT']
require 'debug/open_nonstop'
else
require 'debug'
end

RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
Expand Down