From f8941027a1e99aff211f836ac7952968a18e2c8b Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Wed, 26 Feb 2025 12:19:52 +0200 Subject: [PATCH 1/5] Add invoice preview with simulated termination date --- app/controllers/api/v1/invoices_controller.rb | 1 + .../invoices/preview/subscriptions_service.rb | 113 ++++++++++++ .../invoices/preview_context_service.rb | 53 ++---- .../preview/subscriptions_service_spec.rb | 163 ++++++++++++++++++ 4 files changed, 294 insertions(+), 36 deletions(-) create mode 100644 app/services/invoices/preview/subscriptions_service.rb create mode 100644 spec/services/invoices/preview/subscriptions_service_spec.rb 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..e98774c2e98 --- /dev/null +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -0,0 +1,113 @@ +# 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 + 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 + Time.zone.parse(terminated_at) + rescue ArgumentError + result.single_validation_failure!(error_code: "invalid_timestamp", field: :terminated_at) + nil + 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..f45541cdbaf --- /dev/null +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "pry" + +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))) + ) + 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 From bf70f7cd6a166f535f9787b5de2024be825accd0 Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Wed, 26 Feb 2025 12:26:07 +0200 Subject: [PATCH 2/5] Remove unnecessary tests from context service --- .../invoices/preview_context_service_spec.rb | 69 ++++--------------- 1 file changed, 13 insertions(+), 56 deletions(-) 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 From 2f979263066112b40e60a5dc32a885209fb24118 Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Wed, 26 Feb 2025 12:30:24 +0200 Subject: [PATCH 3/5] Remove debug code --- spec/services/invoices/preview/subscriptions_service_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/services/invoices/preview/subscriptions_service_spec.rb b/spec/services/invoices/preview/subscriptions_service_spec.rb index f45541cdbaf..b5e353601a9 100644 --- a/spec/services/invoices/preview/subscriptions_service_spec.rb +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "pry" - RSpec.describe Invoices::Preview::SubscriptionsService, type: :service do let(:result) { described_class.call(organization:, customer:, params:) } From b14db43f92123c36a17f30e3264e86010d31c4c1 Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Thu, 27 Feb 2025 09:05:52 +0200 Subject: [PATCH 4/5] Fix subscription status not being marked as terminated --- app/services/invoices/preview/subscriptions_service.rb | 1 + spec/services/invoices/preview/subscriptions_service_spec.rb | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/services/invoices/preview/subscriptions_service.rb b/app/services/invoices/preview/subscriptions_service.rb index e98774c2e98..50eeeabeb31 100644 --- a/app/services/invoices/preview/subscriptions_service.rb +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -41,6 +41,7 @@ def terminate_subscriptions customer_subscriptions.map do |subscription| subscription.terminated_at = terminated_at + subscription.status = :terminated subscription end end diff --git a/spec/services/invoices/preview/subscriptions_service_spec.rb b/spec/services/invoices/preview/subscriptions_service_spec.rb index b5e353601a9..28662759436 100644 --- a/spec/services/invoices/preview/subscriptions_service_spec.rb +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -83,7 +83,10 @@ 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))) + .and(have_attributes( + terminated_at: terminated_at.change(usec: 0), + status: "terminated" + )) ) end end From f1ddc6bb56e27436316ba635d3e63b9bab7d5c67 Mon Sep 17 00:00:00 2001 From: Oleksandr Chebotarov Date: Thu, 27 Feb 2025 16:41:39 +0200 Subject: [PATCH 5/5] Use ready utils function to validate timestamp --- app/services/invoices/preview/subscriptions_service.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/services/invoices/preview/subscriptions_service.rb b/app/services/invoices/preview/subscriptions_service.rb index 50eeeabeb31..3ead0eabd05 100644 --- a/app/services/invoices/preview/subscriptions_service.rb +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -92,10 +92,12 @@ def valid_termination? end def parsed_terminated_at - Time.zone.parse(terminated_at) - rescue ArgumentError - result.single_validation_failure!(error_code: "invalid_timestamp", field: :terminated_at) - nil + 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