Skip to content

Commit

Permalink
feat(invoice-preview): add logic for applying coupons on preview invo…
Browse files Browse the repository at this point in the history
…ice (#3073)

## Context

Preview feature enables fetching first Lago invoice. This invoice is not
persisted and is calculated on the fly.

## Description

This PR adds service for applying coupons on preview invoice. The goal
was not to affect heavily main service for applying coupons on invoices.

In order not to repeat the code, some common logic is extracted s that
it can be used on both places
  • Loading branch information
lovrocolic authored Jan 21, 2025
1 parent c730177 commit cf691a0
Show file tree
Hide file tree
Showing 11 changed files with 678 additions and 52 deletions.
7 changes: 7 additions & 0 deletions app/models/applied_coupon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ def mark_as_terminated!(timestamp = Time.zone.now)
self.terminated_at ||= timestamp
terminated!
end

def remaining_amount
return @remaining_amount if defined?(@remaining_amount)

already_applied_amount = credits.sum(&:amount_cents)
@remaining_amount = amount_cents - already_applied_amount
end
end

# == Schema Information
Expand Down
6 changes: 6 additions & 0 deletions app/models/fee.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ def basic_rate_percentage?
end
end

def compute_precise_credit_amount_cents(credit_amount, base_amount_cents)
return 0 if base_amount_cents.zero?

(credit_amount * (amount_cents - precise_coupons_amount_cents)).fdiv(base_amount_cents)
end

def sub_total_excluding_taxes_amount_cents
amount_cents - precise_coupons_amount_cents
end
Expand Down
41 changes: 41 additions & 0 deletions app/services/applied_coupons/amount_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module AppliedCoupons
class AmountService < BaseService
def initialize(applied_coupon:, base_amount_cents:)
@applied_coupon = applied_coupon
@base_amount_cents = base_amount_cents

super
end

def call
return result.not_found_failure!(resource: 'applied_coupon') unless applied_coupon

result.amount = compute_amount
result
end

private

attr_reader :applied_coupon, :base_amount_cents

def compute_amount
if applied_coupon.coupon.percentage?
discounted_value = base_amount_cents * applied_coupon.percentage_rate.fdiv(100)

return (discounted_value >= base_amount_cents) ? base_amount_cents : discounted_value.round
end

if applied_coupon.recurring? || applied_coupon.forever?
return base_amount_cents if applied_coupon.amount_cents > base_amount_cents

applied_coupon.amount_cents
else
return base_amount_cents if applied_coupon.remaining_amount > base_amount_cents

applied_coupon.remaining_amount
end
end
end
end
92 changes: 92 additions & 0 deletions app/services/coupons/preview_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

module Coupons
class PreviewService < BaseService
def initialize(invoice:, applied_coupons:)
@invoice = invoice
@applied_coupons = applied_coupons

super
end

def call
return result.not_found_failure!(resource: 'invoice') unless invoice
return result.not_found_failure!(resource: 'applied_coupons') unless applied_coupons

result.credits = []

applied_coupons.each do |applied_coupon|
break unless invoice.sub_total_excluding_taxes_amount_cents&.positive?
next unless invoice.currency == applied_coupon.amount_currency

fees = fees(applied_coupon)

next if fees.none?

base_amount_cents = base_amount_cents(applied_coupon, fees)
credit = add_credit(applied_coupon, fees, base_amount_cents)

result.credits << credit
invoice.credits << credit
end

result.invoice = invoice
result
end

private

attr_reader :applied_coupons, :invoice

def add_credit(applied_coupon, fees, base_amount_cents)
credit_amount = AppliedCoupons::AmountService.call(applied_coupon:, base_amount_cents:).amount
new_credit = Credit.new(
invoice:,
applied_coupon:,
amount_cents: credit_amount,
amount_currency: invoice.currency,
before_taxes: true
)

fees.each do |fee|
unless base_amount_cents.zero?
fee.precise_coupons_amount_cents += fee.compute_precise_credit_amount_cents(credit_amount, base_amount_cents)
end

fee.precise_coupons_amount_cents = fee.amount_cents if fee.amount_cents < fee.precise_coupons_amount_cents
end

invoice.coupons_amount_cents += new_credit.amount_cents
invoice.sub_total_excluding_taxes_amount_cents -= new_credit.amount_cents

new_credit
end

def base_amount_cents(applied_coupon, fees)
if applied_coupon.coupon.limited_billable_metrics? || applied_coupon.coupon.limited_plans?
fees.sum(&:amount_cents)
else
invoice.sub_total_excluding_taxes_amount_cents
end
end

# TODO: update later when charges will be added to the preview
def fees(applied_coupon)
if applied_coupon.coupon.limited_billable_metrics?
Fee.none
elsif applied_coupon.coupon.limited_plans?
plan_related_fees(applied_coupon)
else
invoice.fees
end
end

def plan_related_fees(applied_coupon)
if applied_coupon.coupon.plans.map(&:id).include?(invoice.subscriptions[0].plan_id)
invoice.fees
else
Fee.none
end
end
end
end
33 changes: 3 additions & 30 deletions app/services/credits/applied_coupon_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def call
return result if already_applied?
return result unless fees.any?

credit_amount = compute_amount
credit_amount = AppliedCoupons::AmountService.call(applied_coupon:, base_amount_cents:).amount

new_credit = Credit.create!(
invoice:,
Expand All @@ -30,9 +30,7 @@ def call

fees.reload.each do |fee|
unless base_amount_cents.zero?
fee.precise_coupons_amount_cents += (
credit_amount * (fee.amount_cents - fee.precise_coupons_amount_cents)
).fdiv(base_amount_cents)
fee.precise_coupons_amount_cents += fee.compute_precise_credit_amount_cents(credit_amount, base_amount_cents)
end

fee.precise_coupons_amount_cents = fee.amount_cents if fee.amount_cents < fee.precise_coupons_amount_cents
Expand Down Expand Up @@ -72,36 +70,11 @@ def already_applied?
invoice.credits.where(applied_coupon_id: applied_coupon.id).exists?
end

def compute_amount
if applied_coupon.coupon.percentage?
discounted_value = base_amount_cents * applied_coupon.percentage_rate.fdiv(100)

return (discounted_value >= base_amount_cents) ? base_amount_cents : discounted_value.round
end

if applied_coupon.recurring? || applied_coupon.forever?
return base_amount_cents if applied_coupon.amount_cents > base_amount_cents

applied_coupon.amount_cents
else
return base_amount_cents if remaining_amount > base_amount_cents

remaining_amount
end
end

def remaining_amount
return @remaining_amount if @remaining_amount

already_applied_amount = applied_coupon.credits.sum(:amount_cents)
@remaining_amount = applied_coupon.amount_cents - already_applied_amount
end

def should_terminate_applied_coupon?(credit_amount)
return false if applied_coupon.forever?

if applied_coupon.once?
applied_coupon.coupon.percentage? || credit_amount >= remaining_amount
applied_coupon.coupon.percentage? || credit_amount >= applied_coupon.remaining_amount
else
applied_coupon.frequency_duration_remaining <= 0
end
Expand Down
13 changes: 10 additions & 3 deletions app/services/invoices/preview_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

module Invoices
class PreviewService < BaseService
def initialize(customer:, subscription:)
def initialize(customer:, subscription:, applied_coupons: [])
@customer = customer
@subscription = subscription
@applied_coupons = applied_coupons

super
end
Expand All @@ -25,7 +26,7 @@ def call
created_at: Time.current,
updated_at: Time.current
)

invoice.credits = []
invoice.subscriptions = [subscription]

add_subscription_fee
Expand All @@ -37,7 +38,7 @@ def call

private

attr_accessor :customer, :subscription, :invoice
attr_accessor :customer, :subscription, :invoice, :applied_coupons

def boundaries
{
Expand Down Expand Up @@ -83,6 +84,12 @@ def add_subscription_fee

def compute_tax_and_totals
invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents)
invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents

if invoice.fees_amount_cents&.positive? && applied_coupons.present?
Coupons::PreviewService.call(invoice:, applied_coupons:)
end

invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents - invoice.coupons_amount_cents

invoice.fees.each do |fee|
Expand Down
11 changes: 11 additions & 0 deletions spec/models/applied_coupon_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,15 @@
subject(:applied_coupon) { build(:applied_coupon) }

it_behaves_like 'paper_trail traceable'

describe '#remaining_amount' do
let(:credit) { build(:credit, applied_coupon:, amount_cents: 10) }
let(:applied_coupon) { build(:applied_coupon, amount_cents: 50) }

it 'returns correct amount' do
applied_coupon.credits = [credit]

expect(applied_coupon.remaining_amount).to eq(40)
end
end
end
14 changes: 14 additions & 0 deletions spec/models/fee_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,20 @@
end
end

describe 'compute_precise_credit_amount_cents' do
subject(:fee) { create(:add_on_fee, amount_cents: 500, precise_coupons_amount_cents: 100) }

it 'returns correct value' do
expect(fee.compute_precise_credit_amount_cents(10, 5)).to eq(800)
end

context 'when base_amount_cents is zero' do
it 'returns zero' do
expect(fee.compute_precise_credit_amount_cents(10, 0)).to eq(0)
end
end
end

describe '#creditable_amount_cents' do
let(:fee) { create(:fee, fee_type:, amount_cents:, invoice:) }
let(:invoice) { create(:invoice, invoice_type: :credit) }
Expand Down
Loading

0 comments on commit cf691a0

Please sign in to comment.