From 3704f89d11b469bfe74bd4411e32bceb0307784f Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Wed, 5 Mar 2025 17:10:15 +0200 Subject: [PATCH] Add support for upgrade and downgrade cases --- app/controllers/api/v1/invoices_controller.rb | 1 + .../subscription_plan_change_service.rb | 77 +++++++++ .../invoices/preview/subscriptions_service.rb | 13 +- .../subscription_plan_change_service_spec.rb | 161 ++++++++++++++++++ .../preview/subscriptions_service_spec.rb | 60 ++++++- 5 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 app/services/invoices/preview/subscription_plan_change_service.rb create mode 100644 spec/services/invoices/preview/subscription_plan_change_service_spec.rb diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb index c7fbf0bb015..9ddee31cb18 100644 --- a/app/controllers/api/v1/invoices_controller.rb +++ b/app/controllers/api/v1/invoices_controller.rb @@ -275,6 +275,7 @@ def preview_params :billing_time, :subscription_at, subscriptions: [ + :plan_code, :terminated_at, external_ids: [] ], diff --git a/app/services/invoices/preview/subscription_plan_change_service.rb b/app/services/invoices/preview/subscription_plan_change_service.rb new file mode 100644 index 00000000000..a2339197fb3 --- /dev/null +++ b/app/services/invoices/preview/subscription_plan_change_service.rb @@ -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 diff --git a/app/services/invoices/preview/subscriptions_service.rb b/app/services/invoices/preview/subscriptions_service.rb index 019eecd89d1..396c3221a2a 100644 --- a/app/services/invoices/preview/subscriptions_service.rb +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -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}", @@ -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:, @@ -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 @@ -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 diff --git a/spec/services/invoices/preview/subscription_plan_change_service_spec.rb b/spec/services/invoices/preview/subscription_plan_change_service_spec.rb new file mode 100644 index 00000000000..f53eeb25659 --- /dev/null +++ b/spec/services/invoices/preview/subscription_plan_change_service_spec.rb @@ -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 diff --git a/spec/services/invoices/preview/subscriptions_service_spec.rb b/spec/services/invoices/preview/subscriptions_service_spec.rb index b87cb2df3a6..2fa1ccb17a4 100644 --- a/spec/services/invoices/preview/subscriptions_service_spec.rb +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -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