Skip to content

Commit

Permalink
feat: #277 Support weekly billing interval (#283)
Browse files Browse the repository at this point in the history
* update billing service with weekly interval

* apply changes for weekly plan on subscription and charge fees

* use built-in .monday? method where needed

* fix invoice boundary for termination case

* update invoice from_date logic

* update from_date invoice logic

* refactor invoice service

* fix comments in invoice service

* add weekly case for last invoice upon termination
  • Loading branch information
lovrocolic authored Jun 24, 2022
1 parent e4f9c91 commit 0aeb212
Show file tree
Hide file tree
Showing 9 changed files with 435 additions and 48 deletions.
6 changes: 6 additions & 0 deletions app/models/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ def upgraded?
plan.yearly_amount_cents <= next_subscription.plan.yearly_amount_cents
end

def downgraded?
return false unless next_subscription

plan.yearly_amount_cents > next_subscription.plan.yearly_amount_cents
end

def trial_end_date
return unless plan.has_trial?

Expand Down
37 changes: 23 additions & 14 deletions app/services/billing_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def call

billable_subscriptions.find_each do |subscription|
if subscription.next_subscription&.pending?
# NOTE: In case of downgrade, subscription remain active until the end of the perdiod,
# NOTE: In case of downgrade, subscription remain active until the end of the period,
# a next subscription is pending, the current one must be terminated
Subscriptions::TerminateJob
.set(wait: rand(240).minutes)
Expand All @@ -27,25 +27,34 @@ def billable_subscriptions
sql = []
today = Time.zone.now

return Subscription.none unless today.day == 1
return Subscription.none unless (today.day == 1 || today.monday?)

# Billed monthly
sql << Subscription.active.joins(:plan)
.merge(Plan.monthly)
.select(:id).to_sql
# For weekly interval we send invoices on Monday
if today.monday?
sql << Subscription.active.joins(:plan)
.merge(Plan.weekly)
.select(:id).to_sql
end

# Bill charges monthly for yearly plans
sql << Subscription.active.joins(:plan)
.merge(Plan.yearly)
.merge(Plan.where(bill_charges_monthly: true))
.select(:id).to_sql
if today.day == 1
# Billed monthly
sql << Subscription.active.joins(:plan)
.merge(Plan.monthly)
.select(:id).to_sql

# We are on the first day of the year
if today.month == 1
# Billed yearly
# Bill charges monthly for yearly plans
sql << Subscription.active.joins(:plan)
.merge(Plan.yearly)
.merge(Plan.where(bill_charges_monthly: true))
.select(:id).to_sql

# We are on the first day of the year
if today.month == 1
# Billed yearly
sql << Subscription.active.joins(:plan)
.merge(Plan.yearly)
.select(:id).to_sql
end
end

Subscription.where("id in (#{sql.join(' UNION ')})")
Expand Down
2 changes: 2 additions & 0 deletions app/services/fees/charge_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ def charges_from_date

if subscription.previous_subscription.upgraded?
date = case plan.interval.to_sym
when :weekly
invoice.charges_from_date.beginning_of_week
when :monthly
invoice.charges_from_date.beginning_of_month
when :yearly
Expand Down
12 changes: 10 additions & 2 deletions app/services/fees/subscription_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Fees
class SubscriptionService < BaseService
WEEK_DURATION = 7.freeze

def initialize(invoice)
@invoice = invoice
super(nil)
Expand Down Expand Up @@ -87,6 +89,8 @@ def first_subscription_amount
# jump to the end of the billing period
if plan.pay_in_advance?
case plan.interval.to_sym
when :weekly
to_date = invoice.to_date.end_of_week
when :monthly
to_date = invoice.to_date.end_of_month
when :yearly
Expand Down Expand Up @@ -133,7 +137,7 @@ def terminated_amount
end
end

# NOTE: number of days between beggining of the period and the termination date
# NOTE: number of days between beginning of the period and the termination date
number_of_day_to_bill = (to_date + 1.day - from_date).to_i

number_of_day_to_bill * single_day_price(plan)
Expand Down Expand Up @@ -213,8 +217,10 @@ def single_day_price(target_plan, optional_from_date = nil)
from_date = optional_from_date || invoice.from_date

# NOTE: Duration in days of full billed period (without termination)
# WARNING: the method only handles beggining of period logic
# WARNING: the method only handles beginning of period logic
duration = case target_plan.interval.to_sym
when :weekly
WEEK_DURATION
when :monthly
(from_date.end_of_month + 1.day) - from_date.beginning_of_month
when :yearly
Expand All @@ -232,6 +238,8 @@ def compute_to_date(base_date, plan)
# NOTE: when plan is pay in advance, the to date should be the
# end of the actual period
case plan.interval.to_sym
when :weekly
invoice.to_date.end_of_week
when :monthly
invoice.to_date.end_of_month
when :yearly
Expand Down
21 changes: 21 additions & 0 deletions app/services/invoices/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,20 @@ def from_date
return @from_date if @from_date.present?

@from_date = case subscription.plan.interval.to_sym
when :weekly
(Time.zone.at(timestamp) - 1.week).to_date
when :monthly
(Time.zone.at(timestamp) - 1.month).to_date
when :yearly
(Time.zone.at(timestamp) - 1.year).to_date
else
raise NotImplementedError
end

# NOTE: In case of termination or upgrade when we are terminating old plan(paying in arrear),
# we should move to the beginning of the billing period
if subscription.terminated? && subscription.plan.pay_in_arrear? && !subscription.downgraded?
@from_date = compute_termination_from_date
end

# NOTE: On first billing period, subscription might start after the computed start of period
Expand Down Expand Up @@ -192,6 +200,19 @@ def create_credit(invoice)
invoice.vat_amount_cents = (invoice.amount_cents * customer.applicable_vat_rate).fdiv(100).ceil
end

def compute_termination_from_date
case subscription.plan.interval.to_sym
when :weekly
Time.zone.at(timestamp).to_date.beginning_of_week
when :monthly
Time.zone.at(timestamp).to_date.beginning_of_month
when :yearly
Time.zone.at(timestamp).to_date.beginning_of_year
else
raise NotImplementedError
end
end

def create_payment(invoice)
case customer.payment_provider&.to_sym
when :stripe
Expand Down
45 changes: 45 additions & 0 deletions spec/models/subscription_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@
end
end

describe '.downgraded?' do
let(:previous_subscription) { nil }
let(:plan) { create(:plan, amount_cents: 100) }

let(:subscription) do
create(
:subscription,
previous_subscription: previous_subscription,
plan: plan,
)
end

context 'without next subscription' do
it { expect(subscription).not_to be_downgraded }
end

context 'with next subscription' do
let(:previous_plan) { create(:plan, amount_cents: 200) }
let(:previous_subscription) do
create(:subscription, plan: previous_plan)
end

before { subscription }

it { expect(previous_subscription).to be_downgraded }

context 'when previous plan was less expensive' do
let(:previous_plan) do
create(:plan, amount_cents: plan.amount_cents - 10)
end

it { expect(previous_subscription).not_to be_downgraded }
end

context 'when plans have different intervals' do
before do
previous_plan.update!(interval: 'yearly')
plan.update!(interval: 'monthly')
end

it { expect(previous_subscription).not_to be_downgraded }
end
end
end

describe '.trial_end_date' do
let(:plan) { create(:plan, trial_period: 3) }
let(:subscription) { create(:active_subscription, plan: plan) }
Expand Down
38 changes: 36 additions & 2 deletions spec/services/billing_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@
describe '.call' do
let(:start_date) { DateTime.parse('20 Feb 2021') }

context 'when billed weekly' do
let(:plan) { create(:plan, interval: :weekly) }

let(:subscription) do
create(
:subscription,
plan: plan,
anniversary_date: start_date,
started_at: Time.zone.now,
)
end

before { subscription }

it 'enqueue a job on billing day' do
current_date = DateTime.parse('20 Jun 2022')

travel_to(current_date) do
billing_service.call

expect(BillSubscriptionJob).to have_been_enqueued
.with(subscription, current_date.to_i)
end
end

it 'does not enqueue a job on other day' do
current_date = DateTime.parse('21 Jun 2022')

travel_to(current_date) do
expect { billing_service.call }.not_to have_enqueued_job
end
end
end

context 'when billed monthly' do
let(:plan) { create(:plan, interval: :monthly) }

Expand Down Expand Up @@ -68,7 +102,7 @@
end

it 'does not enqueue a job on other day' do
current_date = DateTime.parse('02 Janv 2022')
current_date = DateTime.parse('02 Jan 2022')

travel_to(current_date) do
expect { billing_service.call }.not_to have_enqueued_job
Expand All @@ -79,7 +113,7 @@
before { plan.update(bill_charges_monthly: true) }

it 'enqueues a job on billing day' do
current_date = DateTime.parse('01 Fev 2022')
current_date = DateTime.parse('01 Feb 2022')

travel_to(current_date) do
billing_service.call
Expand Down
Loading

0 comments on commit 0aeb212

Please sign in to comment.