diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb index f67d314d632..c7fbf0bb015 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: [ + :terminated_at, external_ids: [] ], coupons: [ diff --git a/app/services/invoices/preview/subscriptions_service.rb b/app/services/invoices/preview/subscriptions_service.rb new file mode 100644 index 00000000000..3ead0eabd05 --- /dev/null +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class SubscriptionsService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(organization:, customer:, params:) + @organization = organization + @customer = customer + @params = params + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + + result.subscriptions = handle_subscriptions + result + rescue ActiveRecord::RecordNotFound => exception + result.not_found_failure!(resource: exception.model.demodulize.underscore) + result + end + + private + + attr_reader :params, :organization, :customer + + def handle_subscriptions + return handle_customer_subscriptions if external_ids.any? + + plan ? [build_subscription] : [] + end + + def handle_customer_subscriptions + terminated_at ? terminate_subscriptions : customer_subscriptions + end + + def terminate_subscriptions + return [] unless valid_termination? + + customer_subscriptions.map do |subscription| + subscription.terminated_at = terminated_at + subscription.status = :terminated + subscription + end + end + + def build_subscription + Subscription.new( + customer: customer, + plan:, + subscription_at: params[:subscription_at].presence || Time.current, + started_at: params[:subscription_at].presence || Time.current, + billing_time:, + created_at: params[:subscription_at].presence || Time.current, + updated_at: Time.current + ) + end + + def billing_time + if Subscription::BILLING_TIME.include?(params[:billing_time]&.to_sym) + params[:billing_time] + else + "calendar" + end + end + + def customer_subscriptions + @customer_subscriptions ||= customer + .subscriptions + .active + .where(external_id: external_ids) + end + + def valid_termination? + if customer_subscriptions.size > 1 + result.single_validation_failure!( + error_code: "only_one_subscription_allowed_for_termination", + field: :subscriptions + ) + end + + if parsed_terminated_at&.to_date&.past? + result.single_validation_failure!( + error_code: "cannot_be_in_past", + field: :terminated_at + ) + end + + result.success? + end + + def parsed_terminated_at + if Utils::Datetime.valid_format?(terminated_at) + Time.zone.parse(terminated_at) + else + result.single_validation_failure!(error_code: "invalid_timestamp", field: :terminated_at) + nil + end + end + + def terminated_at + params.dig(:subscriptions, :terminated_at) + end + + def external_ids + Array(params.dig(:subscriptions, :external_ids)) + end + + def plan + organization.plans.find_by!(code: params[:plan_code]) + end + end + end +end diff --git a/app/services/invoices/preview_context_service.rb b/app/services/invoices/preview_context_service.rb index 1ce2d872ada..fab2316558d 100644 --- a/app/services/invoices/preview_context_service.rb +++ b/app/services/invoices/preview_context_service.rb @@ -12,12 +12,23 @@ def initialize(organization:, params:) def call result.customer = find_or_build_customer - result.subscriptions = find_or_build_subscriptions result.applied_coupons = find_or_build_applied_coupons + + subscriptions_service = ::Invoices::Preview::SubscriptionsService.call( + organization: organization, + customer: result.customer, + params: subscription_params + ) + + if subscriptions_service.success? + result.subscriptions = subscriptions_service.subscriptions + else + result.fail_with_error!(subscriptions_service.error) + end + result rescue ActiveRecord::RecordNotFound => exception result.not_found_failure!(resource: exception.model.demodulize.underscore) - ensure result end @@ -25,6 +36,10 @@ def call attr_reader :params, :organization + def subscription_params + params.slice(:billing_time, :plan_code, :subscription_at, :subscriptions) + end + def find_or_build_customer customer_params = params[:customer] || {} @@ -70,40 +85,6 @@ def build_customer_integration(customer, attrs) customer.integration_customers.build(integration:, type:) end - def build_subscription - billing_time = if Subscription::BILLING_TIME.include?(params[:billing_time]&.to_sym) - params[:billing_time] - else - "calendar" - end - - Subscription.new( - customer: result.customer, - plan:, - subscription_at: params[:subscription_at].presence || Time.current, - started_at: params[:subscription_at].presence || Time.current, - billing_time:, - created_at: params[:subscription_at].presence || Time.current, - updated_at: Time.current - ) - end - - def find_or_build_subscriptions - subscriptions_params = params[:subscriptions] || {} - - if subscriptions_params[:external_ids].present? - result.customer.subscriptions.active.where(external_id: subscriptions_params[:external_ids]) - else - return [] if !plan || !result.customer - - [build_subscription] - end - end - - def plan - organization.plans.find_by!(code: params[:plan_code]) - end - def find_or_build_applied_coupons applied_coupons = result.customer .applied_coupons.active diff --git a/spec/services/invoices/preview/subscriptions_service_spec.rb b/spec/services/invoices/preview/subscriptions_service_spec.rb new file mode 100644 index 00000000000..28662759436 --- /dev/null +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +RSpec.describe Invoices::Preview::SubscriptionsService, type: :service do + let(:result) { described_class.call(organization:, customer:, params:) } + + describe "#call" do + subject { result.subscriptions } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when customer is missing" do + let(:customer) { nil } + let(:params) { {} } + + it "returns a failed result with customer not found error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when external_ids are provided" do + let!(:subscriptions) { create_pair(:subscription, customer:) } + 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) + } + } + end + + it "returns persisted customer subscriptions" do + expect(subject.pluck(:external_id)).to match_array subscriptions.map(&:external_id) + end + end + + context "when terminated at is provided" do + let(:external_ids) { [subscriptions.first.external_id] } + let(:terminated_at) { generate(:future_date) } + + let(:params) do + { + subscriptions: { + external_ids: external_ids, + terminated_at: terminated_at.to_s + } + } + end + + context "when invalid timestamp provided" do + let(:terminated_at) { "2025" } + + it "returns a failed result with invalid timestamp error" do + expect(result).not_to be_success + expect(result.error.messages).to match(terminated_at: ["invalid_timestamp"]) + end + end + + context "when past timestamp provided" do + let(:terminated_at) { generate(:past_date) } + + it "returns a failed result with past timestamp error" do + expect(result).not_to be_success + expect(result.error.messages).to match(terminated_at: ["cannot_be_in_past"]) + end + end + + context "when multiple subscriptions passed" do + let(:external_ids) { subscriptions.map(&:external_id) } + + it "returns a failed result with multiple subscriptions error" do + expect(result).not_to be_success + + expect(result.error.messages) + .to match(subscriptions: ["only_one_subscription_allowed_for_termination"]) + end + end + + context "when all validations passed" do + it "returns result with subscriptions marked as terminated" do + expect(subject).to all( + be_a(Subscription) + .and(have_attributes( + terminated_at: terminated_at.change(usec: 0), + status: "terminated" + )) + ) + end + end + end + end + + context "when external_ids are not provided" do + let(:params) do + { + billing_time:, + plan_code: plan&.code, + subscription_at: subscription_at&.iso8601 + } + end + + context "when plan matching provided code exists" do + let(:plan) { create(:plan, organization:) } + + before { freeze_time } + + context "when billing time and subscription date are present" do + let(:subscription_at) { generate(:past_date) } + let(:billing_time) { "anniversary" } + + it "returns new subscription with provided params" do + expect(subject) + .to all( + be_a(Subscription) + .and(have_attributes( + customer:, + plan:, + subscription_at: subscription_at, + started_at: subscription_at, + billing_time: params[:billing_time] + )) + ) + end + end + + context "when billing time and subscription date are missing" do + let(:subscription_at) { nil } + let(:billing_time) { nil } + + it "returns new subscription with default values for subscription date and billing time" do + expect(subject) + .to all( + be_a(Subscription) + .and(have_attributes( + customer:, + plan:, + subscription_at: Time.current, + started_at: Time.current, + billing_time: "calendar" + )) + ) + end + end + end + + context "when plan matching provided code does not exist" do + let(:plan) { nil } + let(:subscription_at) { nil } + let(:billing_time) { nil } + + it "returns nil" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("plan_not_found") + + expect(subject).to be_nil + end + end + end + end +end diff --git a/spec/services/invoices/preview_context_service_spec.rb b/spec/services/invoices/preview_context_service_spec.rb index d59a1874207..6c1890b4a42 100644 --- a/spec/services/invoices/preview_context_service_spec.rb +++ b/spec/services/invoices/preview_context_service_spec.rb @@ -182,50 +182,29 @@ } end - context "when plan matching provided code exists" do + context "with valid params" do let(:plan) { create(:plan, organization:) } + let(:subscription_at) { generate(:past_date) } + let(:billing_time) { "anniversary" } before { freeze_time } - context "when billing time and subscription date are present" do - let(:subscription_at) { generate(:past_date) } - let(:billing_time) { "anniversary" } - - it "returns new subscription with provided params" do - expect(subject) - .to all( - be_a(Subscription) - .and(have_attributes( - customer:, - plan:, - subscription_at: subscription_at, - started_at: subscription_at, - billing_time: params[:billing_time] - )) - ) - end - end - - context "when billing time and subscription date are missing" do - let(:subscription_at) { nil } - let(:billing_time) { nil } - - it "returns new subscription with default values for subscription date and billing time" do - expect(subject) - .to all( - be_a(Subscription).and(have_attributes( + it "returns new subscription with provided params" do + expect(subject) + .to all( + be_a(Subscription) + .and(have_attributes( customer:, plan:, - subscription_at: Time.current, - started_at: Time.current, - billing_time: "calendar" + subscription_at: subscription_at, + started_at: subscription_at, + billing_time: params[:billing_time] )) - ) - end + ) end end - context "when plan matching provided code does not exist" do + context "with invalid params" do let(:plan) { nil } let(:subscription_at) { nil } let(:billing_time) { nil } @@ -238,28 +217,6 @@ expect(subject).to be_nil end end - - context "when subscriptions are fetched from the database" do - let(:subscription1) { create(:subscription, customer:) } - let(:subscription2) { create(:subscription, customer:) } - let(:params) do - { - customer: {external_id: customer.external_id}, - subscriptions: { - external_ids: [subscription1.external_id, subscription2.external_id] - } - } - end - - before do - subscription1 - subscription2 - end - - it "returns subscriptions that are persisted" do - expect(subject.pluck(:external_id)).to eq([subscription1.external_id, subscription2.external_id]) - end - end end describe "#applied_coupons" do