From 4a03e98a842c7422b8c837f0656ca33d42d634e6 Mon Sep 17 00:00:00 2001 From: osmarluz Date: Wed, 13 May 2026 10:39:03 +0000 Subject: [PATCH 1/3] feat(payment-gated-subs): delegate downgrade logic to its own service --- app/services/subscriptions/create_service.rb | 58 +---- .../subscriptions/plan_downgrade_service.rb | 93 ++++++++ .../subscriptions/create_service_spec.rb | 22 ++ .../plan_downgrade_service_spec.rb | 210 ++++++++++++++++++ 4 files changed, 326 insertions(+), 57 deletions(-) create mode 100644 app/services/subscriptions/plan_downgrade_service.rb create mode 100644 spec/services/subscriptions/plan_downgrade_service_spec.rb diff --git a/app/services/subscriptions/create_service.rb b/app/services/subscriptions/create_service.rb index 1ed31fdea53..490c616d7cd 100644 --- a/app/services/subscriptions/create_service.rb +++ b/app/services/subscriptions/create_service.rb @@ -187,53 +187,7 @@ def upgrade_subscription end def downgrade_subscription - if current_subscription.starting_in_the_future? - update_pending_subscription - - return current_subscription - end - - cancel_pending_subscription if pending_subscription? - - # NOTE: When downgrading a subscription, we keep the current one active - # until the next billing day. The new subscription will become active at this date - new_sub = current_subscription.next_subscriptions.create!( - organization_id: customer.organization_id, - customer:, - plan: params.key?(:plan_overrides) ? override_plan(plan) : plan, - name:, - external_id: current_subscription.external_id, - subscription_at: current_subscription.subscription_at, - status: :pending, - billing_time: current_subscription.billing_time, - ending_at: params.key?(:ending_at) ? params[:ending_at] : current_subscription.ending_at, - progressive_billing_disabled: params[:progressive_billing_disabled] || false - ) - - if params.key?(:payment_method) - new_sub.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) - new_sub.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) - new_sub.save! - end - - InvoiceCustomSections::AttachToResourceService.call(resource: new_sub, params:) - - after_commit do - SendWebhookJob.perform_later("subscription.updated", current_subscription) - Utils::ActivityLog.produce(current_subscription, "subscription.updated") - end - - current_subscription - end - - def pending_subscription? - return false unless current_subscription&.next_subscription - - current_subscription.next_subscription.pending? - end - - def cancel_pending_subscription - current_subscription.next_subscription.mark_as_canceled! + PlanDowngradeService.call!(customer:, current_subscription:, plan:, params:).subscription end def subscription_type @@ -249,16 +203,6 @@ def currency_missmatch?(old_plan, new_plan) old_plan.amount_currency != new_plan.amount_currency end - def update_pending_subscription - current_subscription.plan = plan - current_subscription.name = name if name.present? - current_subscription.save! - - if current_subscription.should_sync_hubspot_subscription? - Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription: current_subscription) - end - end - def apply_activation_rules(subscription) return unless params[:activation_rules]&.present? diff --git a/app/services/subscriptions/plan_downgrade_service.rb b/app/services/subscriptions/plan_downgrade_service.rb new file mode 100644 index 00000000000..c019e6ba67e --- /dev/null +++ b/app/services/subscriptions/plan_downgrade_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Subscriptions + class PlanDowngradeService < BaseService + Result = BaseResult[:subscription] + + def initialize(customer:, current_subscription:, plan:, params:) + @customer = customer + @current_subscription = current_subscription + @plan = plan + + @params = params + @name = params[:name].to_s.strip + super + end + + def call + if current_subscription.starting_in_the_future? + update_pending_subscription + + result.subscription = current_subscription + return result + end + + ActiveRecord::Base.transaction do + cancel_pending_subscription if pending_subscription? + + # NOTE: When downgrading a subscription, we keep the current one active + # until the next billing day. The new subscription will become active at this date + new_sub = current_subscription.next_subscriptions.create!( + organization_id: customer.organization_id, + customer:, + plan: params.key?(:plan_overrides) ? override_plan : plan, + name:, + external_id: current_subscription.external_id, + subscription_at: current_subscription.subscription_at, + status: :pending, + billing_time: current_subscription.billing_time, + ending_at: params.key?(:ending_at) ? params[:ending_at] : current_subscription.ending_at, + progressive_billing_disabled: params[:progressive_billing_disabled] || false + ) + + if params.key?(:payment_method) + new_sub.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + new_sub.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + new_sub.save! + end + + InvoiceCustomSections::AttachToResourceService.call(resource: new_sub, params:) + + after_commit do + SendWebhookJob.perform_later("subscription.updated", current_subscription) + Utils::ActivityLog.produce(current_subscription, "subscription.updated") + end + end + + result.subscription = current_subscription + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :customer, :current_subscription, :plan, :params, :name + + def update_pending_subscription + current_subscription.plan = plan + current_subscription.name = name if name.present? + current_subscription.save! + + if current_subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription: current_subscription) + end + end + + def override_plan + Plans::OverrideService.call(plan:, params: params[:plan_overrides].to_h.with_indifferent_access).plan + end + + def cancel_pending_subscription + current_subscription.next_subscription.mark_as_canceled! + end + + def pending_subscription? + return false unless current_subscription.next_subscription + + current_subscription.next_subscription.pending? + end + end +end diff --git a/spec/services/subscriptions/create_service_spec.rb b/spec/services/subscriptions/create_service_spec.rb index bc31e4d4269..da0aa4bc006 100644 --- a/spec/services/subscriptions/create_service_spec.rb +++ b/spec/services/subscriptions/create_service_spec.rb @@ -1353,6 +1353,28 @@ expect(result.error.messages[:subscription]).to eq(["subscription_incomplete"]) end end + + context "when subscription downgrade fails" do + let(:result_failure) do + BaseService::Result.new.validation_failure!( + errors: {billing_time: ["value_is_invalid"]} + ) + end + + before do + allow(Subscriptions::PlanDowngradeService) + .to receive(:call) + .and_return(result_failure) + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({billing_time: ["value_is_invalid"]}) + end + end end end end diff --git a/spec/services/subscriptions/plan_downgrade_service_spec.rb b/spec/services/subscriptions/plan_downgrade_service_spec.rb new file mode 100644 index 00000000000..a6ecb6a756a --- /dev/null +++ b/spec/services/subscriptions/plan_downgrade_service_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::PlanDowngradeService do + subject(:result) do + described_class.call(customer:, current_subscription: subscription, plan:, params:) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan: old_plan, + subscription_at: Time.current, + external_id: SecureRandom.uuid + ) + end + + let(:old_plan) { create(:plan, amount_cents: 100, organization:, amount_currency: currency) } + let(:customer) { create(:customer, :with_hubspot_integration, organization:, currency:) } + let(:organization) { create(:organization) } + let(:currency) { "EUR" } + let(:plan) { create(:plan, amount_cents: 50, organization:) } + let(:params) { {name: subscription_name} } + let(:subscription_name) { "new invoice display name" } + + describe "#call" do + it "creates a new pending next subscription" do + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.id).not_to eq(subscription.id) + expect(next_subscription).to be_pending + expect(next_subscription.name).to eq(subscription_name) + expect(next_subscription.plan_id).to eq(plan.id) + expect(next_subscription.subscription_at).to eq(subscription.subscription_at) + expect(next_subscription.previous_subscription).to eq(subscription) + expect(next_subscription.ending_at).to eq(subscription.ending_at) + expect(next_subscription.lifetime_usage).to be_nil + expect(next_subscription.payment_method_id).to be_nil + expect(next_subscription.payment_method_type).to eq("provider") + end + + it "sends subscription.updated webhook on the current subscription" do + result + expect(SendWebhookJob).to have_been_enqueued.with("subscription.updated", subscription) + end + + it "produces a subscription.updated activity log on the current subscription" do + result + expect(Utils::ActivityLog).to have_produced("subscription.updated").with(subscription) + end + + it "keeps the current subscription active" do + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.next_subscription).to be_present + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + name: subscription_name, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + } + end + + before { payment_method } + + it "propagates the payment method to the new subscription" do + next_subscription = result.subscription.next_subscription + expect(next_subscription.payment_method_id).to eq(payment_method.id) + expect(next_subscription.payment_method_type).to eq("provider") + end + end + + context "with invoice custom sections" do + let(:section) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:params) do + { + name: subscription_name, + invoice_custom_section: {invoice_custom_section_codes: [section.code]} + } + end + + before do + section + CurrentContext.source = "api" + end + + it "attaches the section to the new subscription" do + expect(result).to be_success + + next_subscription = result.subscription.next_subscription.reload + expect(next_subscription.applied_invoice_custom_sections.count).to be(1) + expect(next_subscription.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section.id) + end + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "does not create fixed charge events on the new subscription" do + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription).to be_pending + expect(next_subscription.fixed_charge_events.count).to eq(0) + end + end + + context "when ending_at is overridden" do + let(:overridden_ending_at) { Time.current.beginning_of_day + 3.months } + let(:params) { {name: subscription_name, ending_at: overridden_ending_at} } + + it "applies the overridden ending_at to the new subscription" do + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.ending_at).to eq(overridden_ending_at) + end + end + + context "with plan overrides", :premium do + let(:params) do + { + name: subscription_name, + plan_overrides: {amount_cents: 25} + } + end + + it "creates the new subscription with the overridden plan" do + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.plan.amount_cents).to eq(25) + expect(next_subscription.plan.parent_id).to eq(plan.id) + end + end + + context "when current subscription is pending" do + let(:subscription) do + create( + :subscription, + :pending, + customer:, + plan: old_plan, + subscription_at: Time.current, + external_id: SecureRandom.uuid + ) + end + + it "returns the existing subscription with updated attributes" do + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription.plan_id).to eq(plan.id) + expect(result.subscription.name).to eq(subscription_name) + end + + context "without a name in params" do + let(:params) { {} } + + it "does not change the existing subscription name" do + original_name = subscription.name + expect(result).to be_success + expect(result.subscription.name).to eq(original_name) + end + end + + context "without Hubspot integration on the customer" do + let(:customer) { create(:customer, organization:, currency:) } + + it "does not enqueue the Hubspot update job" do + result + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).not_to have_been_enqueued + end + end + end + + context "with pending next subscription" do + let(:existing_next_subscription) do + create( + :subscription, + status: :pending, + previous_subscription: subscription, + organization:, + customer: + ) + end + + before { existing_next_subscription } + + it "cancels the existing pending next subscription" do + expect(result).to be_success + expect(existing_next_subscription.reload).to be_canceled + end + end + end +end From d4fdb919cbf2373921a738ec389d30fc02799ac6 Mon Sep 17 00:00:00 2001 From: osmarluz Date: Fri, 15 May 2026 11:20:20 +0000 Subject: [PATCH 2/3] feat(payment-gated-subs): address PR comments --- app/services/subscriptions/plan_downgrade_service.rb | 2 -- spec/services/subscriptions/create_service_spec.rb | 4 ++-- .../subscriptions/plan_downgrade_service_spec.rb | 12 ++++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/services/subscriptions/plan_downgrade_service.rb b/app/services/subscriptions/plan_downgrade_service.rb index c019e6ba67e..0a78a4fc3a6 100644 --- a/app/services/subscriptions/plan_downgrade_service.rb +++ b/app/services/subscriptions/plan_downgrade_service.rb @@ -58,8 +58,6 @@ def call result rescue ActiveRecord::RecordInvalid => e result.record_validation_failure!(record: e.record) - rescue BaseService::FailedResult => e - result.fail_with_error!(e) end private diff --git a/spec/services/subscriptions/create_service_spec.rb b/spec/services/subscriptions/create_service_spec.rb index da0aa4bc006..399e49422c1 100644 --- a/spec/services/subscriptions/create_service_spec.rb +++ b/spec/services/subscriptions/create_service_spec.rb @@ -963,7 +963,7 @@ before do allow(Subscriptions::PlanUpgradeService) - .to receive(:call) + .to receive(:call!) .and_return(result_failure) end @@ -1363,7 +1363,7 @@ before do allow(Subscriptions::PlanDowngradeService) - .to receive(:call) + .to receive(:call!) .and_return(result_failure) end diff --git a/spec/services/subscriptions/plan_downgrade_service_spec.rb b/spec/services/subscriptions/plan_downgrade_service_spec.rb index a6ecb6a756a..26819b59568 100644 --- a/spec/services/subscriptions/plan_downgrade_service_spec.rb +++ b/spec/services/subscriptions/plan_downgrade_service_spec.rb @@ -206,5 +206,17 @@ expect(existing_next_subscription.reload).to be_canceled end end + + context "when the new subscription fails validation" do + before do + allow(subscription.next_subscriptions).to receive(:create!) + .and_raise(ActiveRecord::RecordInvalid.new(Subscription.new)) + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end end end From 8f5261b6a1f5169b46bb27d65804464ad0b40147 Mon Sep 17 00:00:00 2001 From: osmarluz Date: Fri, 15 May 2026 12:29:33 +0000 Subject: [PATCH 3/3] feat(payment-gated-subs): fix failing tests --- spec/services/subscriptions/create_service_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/services/subscriptions/create_service_spec.rb b/spec/services/subscriptions/create_service_spec.rb index 399e49422c1..da0aa4bc006 100644 --- a/spec/services/subscriptions/create_service_spec.rb +++ b/spec/services/subscriptions/create_service_spec.rb @@ -963,7 +963,7 @@ before do allow(Subscriptions::PlanUpgradeService) - .to receive(:call!) + .to receive(:call) .and_return(result_failure) end @@ -1363,7 +1363,7 @@ before do allow(Subscriptions::PlanDowngradeService) - .to receive(:call!) + .to receive(:call) .and_return(result_failure) end