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(invoice-preview): Add support for simulating subscription upgrade and downgrade #3297

Open
wants to merge 1 commit into
base: misc-refactor-invoice-preview-subscription-handling
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions app/controllers/api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ def preview_params
:billing_time,
:subscription_at,
subscriptions: [
:plan_code,
:terminated_at,
external_ids: []
],
Expand Down
77 changes: 77 additions & 0 deletions app/services/invoices/preview/subscription_plan_change_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

module Invoices
module Preview
class SubscriptionPlanChangeService < BaseService
Result = BaseResult[:subscriptions]

def initialize(current_subscription:, target_plan_code:)
@current_subscription = current_subscription
@target_plan_code = target_plan_code
super
end

def call
return result.not_found_failure!(resource: "subscription") unless current_subscription
return result.not_found_failure!(resource: "plan") unless target_plan

if target_plan.id == current_subscription.plan_id
return result.single_validation_failure!(
error_code: "new_plan_should_be_different_from_existing_plan"
)
end

result.subscriptions = [terminated_current_subscription, new_subscription].compact
result
end

private

attr_reader :current_subscription, :target_plan_code, :context

delegate :organization, :customer, to: :current_subscription

def terminated_current_subscription
current_subscription.terminated_at = termination_date
current_subscription.status = :terminated

current_subscription
end

def new_subscription
return unless target_plan.pay_in_advance?

Subscription.new(
customer:,
plan: target_plan,
name: target_plan.name,
external_id: current_subscription.external_id,
previous_subscription_id: current_subscription.id,
subscription_at: current_subscription.subscription_at,
billing_time: current_subscription.billing_time,
ending_at: current_subscription.ending_at,
status: :active,
started_at: upgrade? ? Time.current : termination_date
)
end

def termination_date
@termination_date ||= if upgrade?
Time.current
else
Subscriptions::DatesService
.new_instance(current_subscription, Time.current, current_usage: true)
.end_of_period + 1.day
end
end

def upgrade?
target_plan.yearly_amount_cents >= current_subscription.plan.yearly_amount_cents
end

def target_plan
@target_plan ||= organization.plans.find_by(code: target_plan_code)
end
end
end
end
13 changes: 12 additions & 1 deletion app/services/invoices/preview/subscriptions_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def call
return result.not_found_failure!(resource: "organization") unless organization
return result.not_found_failure!(resource: "customer") unless customer

if [:termination].include?(context)
if [:termination, :plan_change].include?(context)
if customer_subscriptions.size > 1
return result.single_validation_failure!(
error_code: "only_one_subscription_allowed_for_#{context}",
Expand All @@ -31,6 +31,11 @@ def call
current_subscription:,
terminated_at:
)
when :plan_change
SubscriptionPlanChangeService.call(
current_subscription:,
target_plan_code:
)
when :proposal
BuildSubscriptionService.call(
customer:,
Expand All @@ -54,6 +59,8 @@ def context
:proposal # Preview for non-existing subscription
elsif terminated_at
:termination
elsif target_plan_code
:plan_change
else
:projection # Preview for existing subscriptions without any modifications
end
Expand All @@ -77,6 +84,10 @@ def terminated_at
def external_ids
Array(params.dig(:subscriptions, :external_ids))
end

def target_plan_code
params.dig(:subscriptions, :plan_code)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Invoices::Preview::SubscriptionPlanChangeService, type: :service do
describe ".call" do
subject(:result) { described_class.call(current_subscription:, target_plan_code:) }

let(:subscriptions) { result.subscriptions }

context "when current subscription is missing" do
let(:current_subscription) { nil }
let(:target_plan_code) { nil }

it "fails with subscription not found error" do
expect(result).to be_failure
expect(result.error.error_code).to eq("subscription_not_found")
end
end

context "when plan matching code does not exist" do
let(:current_subscription) { create(:subscription) }
let(:target_plan_code) { "non-existing-code" }

it "fails with plan not found error" do
expect(result).to be_failure
expect(result.error.error_code).to eq("plan_not_found")
end
end

context "when current subscription and matching plan are present" do
let!(:current_subscription) { create(:subscription, plan: current_plan, organization:) }
let(:current_plan) { create(:plan, organization:) }
let(:organization) { create(:organization) }
let(:target_plan_code) { target_plan.code }

context "when target plan is the same as current subscription's plan" do
let(:target_plan) { current_plan }

it "fails with invalid target plan error" do
expect(result).to be_failure

expect(result.error.messages)
.to match(base: ["new_plan_should_be_different_from_existing_plan"])
end

it "does not change persist any changes to the current subscription" do
expect { subject }.not_to change { current_subscription.reload.attributes }
end

it "does not create any subscription" do
expect { subject }.not_to change(Subscription, :count)
end
end

context "when target plan is not the same as current subscription's plan" do
let(:target_plan) { create(:plan, organization:, pay_in_advance:, amount_cents:) }

before { travel_to Time.zone.parse("05-02-2025 12:34:56") }

context "when target plan is pay in advance" do
let(:pay_in_advance) { true }

context "when target plan is same price or more expensive" do
let(:amount_cents) { current_plan.amount_cents }

it "returns array containing terminated current and new subscriptions" do
expect(result).to be_success
expect(subscriptions).to match_array [current_subscription, Subscription]

expect(subscriptions.first)
.to have_attributes(status: "terminated", terminated_at: Time.current)

expect(subscriptions.second)
.to be_new_record
.and have_attributes(status: "active", started_at: Time.current, name: target_plan.name)
end

it "does not change persist any changes to the current subscription" do
expect { subject }.not_to change { current_subscription.reload.attributes }
end

it "does not create any subscription" do
expect { subject }.not_to change(Subscription, :count)
end
end

context "when target plan is cheaper" do
let(:amount_cents) { current_plan.amount_cents - 1 }
let(:start_of_next_billing_period) { Time.zone.parse("01-03-2025").end_of_day }

it "returns array containing terminated current and new subscriptions" do
expect(result).to be_success
expect(subscriptions).to match_array [current_subscription, Subscription]

expect(subscriptions.first)
.to have_attributes(status: "terminated", terminated_at: start_of_next_billing_period)

expect(subscriptions.second)
.to be_new_record
.and have_attributes(status: "active", started_at: start_of_next_billing_period, name: target_plan.name)
end

it "does not change persist any changes to the current subscription" do
expect { subject }.not_to change { current_subscription.reload.attributes }
end

it "does not create any subscription" do
expect { subject }.not_to change(Subscription, :count)
end
end
end

context "when target plan is not pay in advance" do
let(:pay_in_advance) { false }

context "when target plan is same price or more expensive" do
let(:amount_cents) { current_plan.amount_cents }

it "returns array containing terminated current subscription" do
expect(result).to be_success
expect(subscriptions).to contain_exactly current_subscription

expect(subscriptions.first)
.to have_attributes(status: "terminated", terminated_at: Time.current)
end

it "does not change persist any changes to the current subscription" do
expect { subject }.not_to change { current_subscription.reload.attributes }
end

it "does not create any subscription" do
expect { subject }.not_to change(Subscription, :count)
end
end

context "when target plan is cheaper" do
let(:amount_cents) { current_plan.amount_cents - 1 }
let(:start_of_next_billing_period) { Time.zone.parse("01-03-2025").end_of_day }

it "returns array containing terminated current subscription" do
expect(result).to be_success
expect(subscriptions).to contain_exactly current_subscription

expect(subscriptions.first)
.to have_attributes(status: "terminated", terminated_at: start_of_next_billing_period)
end

it "does not change persist any changes to the current subscription" do
expect { subject }.not_to change { current_subscription.reload.attributes }
end

it "does not create any subscription" do
expect { subject }.not_to change(Subscription, :count)
end
end
end
end
end
end
end
60 changes: 52 additions & 8 deletions spec/services/invoices/preview/subscriptions_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,61 @@
let(:subscription_ids) { subscriptions.map(&:external_id) }

context "when terminated at is not provided" do
let(:params) do
{
subscriptions: {
external_ids: subscriptions.map(&:external_id)
context "when plan code is present" do
let(:params) do
{
subscriptions: {
external_ids:,
plan_code: target_plan.code
}
}
}
end

let(:target_plan) { create(:plan, organization:, pay_in_advance: true) }

context "when multiple subscriptions passed" do
let(:external_ids) { subscriptions.map(&:external_id) }

it "fails with multiple subscriptions error" do
expect(result).to be_failure

expect(result.error.messages)
.to match(subscriptions: ["only_one_subscription_allowed_for_plan_change"])
end
end

context "when single subscription passed" do
let(:external_ids) { [subscriptions.first.external_id] }

before { freeze_time }

it "returns result with subscriptions marked as terminated and new subscription" do
expect(result).to be_success
expect(subject).to match_array [subscriptions.first, Subscription]

expect(subject.first)
.to have_attributes(status: "terminated", terminated_at: Time.current)

expect(subject.second)
.to be_new_record
.and have_attributes(status: "active", started_at: Time.current, name: target_plan.name)
end
end
end

it "returns persisted customer subscriptions" do
expect(result).to be_success
expect(subject.pluck(:external_id)).to match_array subscriptions.map(&:external_id)
context "when plan code is missing" do
let(:params) do
{
subscriptions: {
external_ids: subscriptions.map(&:external_id)
}
}
end

it "returns persisted customer subscriptions" do
expect(result).to be_success
expect(subject.pluck(:external_id)).to match_array subscriptions.map(&:external_id)
end
end
end

Expand Down