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 invoice preview with simulated termination date on subscription #3257

Open
wants to merge 4 commits into
base: feat-invoice-preview-three
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: [
:terminated_at,
external_ids: []
],
coupons: [
Expand Down
114 changes: 114 additions & 0 deletions app/services/invoices/preview/subscriptions_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# 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
Time.zone.parse(terminated_at)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time.zone.parse in itselft is not 100% reliable when it comes to validating dates...
We have Utils::Datetime.valid_format?(terminated_at) to improve it.

irb> Time.zone.parse('ff')
=> nil
irb> Time.zone.parse('2023')
(irb):3:in `<main>': argument out of range (ArgumentError)
irb> Time.zone.parse('12')
=> Wed, 12 Feb 2025 00:00:00.000000000 UTC +00:00

If that helps, we could even add a method doing both the validation and the parsing

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
53 changes: 17 additions & 36 deletions app/services/invoices/preview_context_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,34 @@ 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

private

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] || {}

Expand Down Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions spec/services/invoices/preview/subscriptions_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading