From 5daab1c64ac027a36b815155d3fd139e7692af32 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 5 Sep 2024 09:29:23 +0200 Subject: [PATCH] feat(ProgressiveBilling) - Add LifetimeUsages::UsageThresholdsCompletionService (#2531) ## Context AI companies want their users to pay before the end of a period if usage skyrockets. The problem being that self-serve companies can overuse their API without paying, triggering lots of costs on their side. ## Description This PR introduces a new `LifetimeUsages::UsageThresholdsCompletionService` which calculates the completion ratios of thresholds. Also we add a `LifetimeUsagesController` combined with new serializer to facilitate fetching and updating lifetime usages through the api. --- .../api/v1/lifetime_usages_controller.rb | 48 +++++ .../subscriptions/lifetime_usage_object.rb | 4 +- .../v1/lifetime_usage_serializer.rb | 28 +++ .../find_last_and_next_thresholds_service.rb | 79 ++------ .../lifetime_usages/update_service.rb | 27 +++ .../usage_thresholds_completion_service.rb | 98 ++++++++++ config/routes.rb | 4 +- schema.graphql | 2 +- schema.json | 2 +- .../lifetime_usage_object_spec.rb | 2 +- .../api/v1/lifetime_usages_controller_spec.rb | 54 ++++++ .../v1/lifetime_usage_serializer_spec.rb | 65 +++++++ ...d_last_and_next_thresholds_service_spec.rb | 14 +- .../lifetime_usages/update_service_spec.rb | 46 +++++ ...sage_thresholds_completion_service_spec.rb | 172 ++++++++++++++++++ 15 files changed, 567 insertions(+), 78 deletions(-) create mode 100644 app/controllers/api/v1/lifetime_usages_controller.rb create mode 100644 app/serializers/v1/lifetime_usage_serializer.rb create mode 100644 app/services/lifetime_usages/update_service.rb create mode 100644 app/services/lifetime_usages/usage_thresholds_completion_service.rb create mode 100644 spec/requests/api/v1/lifetime_usages_controller_spec.rb create mode 100644 spec/serializers/v1/lifetime_usage_serializer_spec.rb create mode 100644 spec/services/lifetime_usages/update_service_spec.rb create mode 100644 spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb diff --git a/app/controllers/api/v1/lifetime_usages_controller.rb b/app/controllers/api/v1/lifetime_usages_controller.rb new file mode 100644 index 00000000000..80bcbe67e5d --- /dev/null +++ b/app/controllers/api/v1/lifetime_usages_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Api + module V1 + class LifetimeUsagesController < Api::BaseController + def show + lifetime_usage = current_organization.subscriptions + .find_by(external_id: params[:subscription_external_id])&.lifetime_usage + + return not_found_error(resource: 'lifetime_usage') unless lifetime_usage + render_lifetime_usage lifetime_usage + end + + def update + lifetime_usage = current_organization.subscriptions + .find_by(external_id: params[:subscription_external_id])&.lifetime_usage + + result = LifetimeUsages::UpdateService.call( + lifetime_usage:, + params: update_params.to_h + ) + if result.success? + render_lifetime_usage lifetime_usage + else + render_error_response(result) + end + end + + private + + def update_params + params.require(:lifetime_usage).permit( + :external_historical_usage_amount_cents + ) + end + + def render_lifetime_usage(lifetime_usage) + render( + json: ::V1::LifetimeUsageSerializer.new( + lifetime_usage, + root_name: 'lifetime_usage', + include: %i[usage_thresholds] + ) + ) + end + end + end +end diff --git a/app/graphql/types/subscriptions/lifetime_usage_object.rb b/app/graphql/types/subscriptions/lifetime_usage_object.rb index db4d8d60b9b..44432c099f0 100644 --- a/app/graphql/types/subscriptions/lifetime_usage_object.rb +++ b/app/graphql/types/subscriptions/lifetime_usage_object.rb @@ -11,7 +11,7 @@ class LifetimeUsageObject < Types::BaseObject field :last_threshold_amount_cents, GraphQL::Types::BigInt, null: true field :next_threshold_amount_cents, GraphQL::Types::BigInt, null: true - field :next_treshold_ratio, GraphQL::Types::Float, null: true + field :next_threshold_ratio, GraphQL::Types::Float, null: true def total_usage_from_datetime object.subscription.subscription_at @@ -25,7 +25,7 @@ def total_usage_to_datetime delegate :last_threshold_amount_cents, :next_threshold_amount_cents, - :next_treshold_ratio, + :next_threshold_ratio, to: :last_and_next_thresholds def last_and_next_thresholds diff --git a/app/serializers/v1/lifetime_usage_serializer.rb b/app/serializers/v1/lifetime_usage_serializer.rb new file mode 100644 index 00000000000..b6bc27fd0a4 --- /dev/null +++ b/app/serializers/v1/lifetime_usage_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module V1 + class LifetimeUsageSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_subscription_id: model.subscription_id, + external_subscription_id: model.subscription.external_id, + external_historical_usage_amount_cents: model.historical_usage_amount_cents, + invoiced_usage_amount_cents: model.invoiced_usage_amount_cents, + current_usage_amount_cents: model.current_usage_amount_cents, + from_datetime: model.subscription.subscription_at&.iso8601, + to_datetime: Time.current.iso8601 + } + + payload.merge!(usage_thresholds) if include?(:usage_thresholds) && model.subscription.plan.usage_thresholds.any? + payload + end + + private + + def usage_thresholds + result = LifetimeUsages::UsageThresholdsCompletionService.call(lifetime_usage: model).raise_if_error! + {usage_thresholds: result.usage_thresholds.map { |r| r.slice(:amount_cents, :completion_ratio, :reached_at) }} + end + end +end diff --git a/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb b/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb index 823a2e16b1f..0a677d05931 100644 --- a/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb +++ b/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb @@ -4,83 +4,32 @@ module LifetimeUsages class FindLastAndNextThresholdsService < BaseService def initialize(lifetime_usage:) @lifetime_usage = lifetime_usage - @thresholds = lifetime_usage.subscription.plan.usage_thresholds super end def call - result.last_threshold_amount_cents = last_threshold_amount_cents - result.next_threshold_amount_cents = next_threshold_amount_cents - result.next_treshold_ratio = next_treshold_ratio - result - end - - private - - attr_reader :lifetime_usage, :thresholds - delegate :organization, :subscription, to: :lifetime_usage - - def last_applied_usage_threshold - return @last_applied_usage_threshold if defined?(@last_applied_usage_threshold) - - subscription_ids = organization.subscriptions - .where(external_id: subscription.external_id, subscription_at: subscription.subscription_at) - .where(canceled_at: nil) - .select(:id) - - @last_applied_usage_threshold = AppliedUsageThreshold - .joins(invoice: :invoice_subscriptions) - .where(invoice_subscriptions: {subscription_id: subscription_ids}) - .order(created_at: :desc) - .first - end - - def next_usage_threshold - return @next_usage_threshold if defined?(@next_usage_threshold) - - @next_usage_threshold = thresholds - .not_recurring - .where('amount_cents > ?', lifetime_usage.total_amount_cents) - .order(amount_cents: :asc) - .first + completion_result = LifetimeUsages::UsageThresholdsCompletionService.call(lifetime_usage:).raise_if_error! - @next_usage_threshold ||= thresholds.recurring.first - end - - def largest_threshold - @largest_threshold ||= thresholds.not_recurring.order(amount_cents: :desc).first - end + index = completion_result.usage_thresholds.rindex { |h| h[:reached_at].present? } + passed_threshold = nil + next_threshold = nil - def last_threshold_amount_cents - last_threshold = last_applied_usage_threshold&.usage_threshold - return unless last_threshold - - if last_threshold.recurring? - recurring_amount = lifetime_usage.total_amount_cents - (largest_threshold.amount_cents || 0) - occurence = recurring_amount / last_threshold.amount_cents - - largest_threshold.amount_cents + occurence * last_threshold.amount_cents + if index + passed_threshold = completion_result.usage_thresholds[index] + next_threshold = completion_result.usage_thresholds[index + 1] else - last_threshold.amount_cents + next_threshold = completion_result.usage_thresholds.first end - end - def next_threshold_amount_cents - return unless next_usage_threshold - return next_usage_threshold.amount_cents unless next_usage_threshold.recurring? - - recurring_amount = lifetime_usage.total_amount_cents - (largest_threshold.amount_cents || 0) - occurence = recurring_amount.fdiv(next_usage_threshold.amount_cents).ceil - - largest_threshold.amount_cents + occurence * next_usage_threshold.amount_cents + result.last_threshold_amount_cents = passed_threshold&.[](:amount_cents) + result.next_threshold_amount_cents = next_threshold&.[](:amount_cents) + result.next_threshold_ratio = next_threshold&.[](:completion_ratio) + result end - def next_treshold_ratio - return unless next_usage_threshold + private - base_amount_cents = lifetime_usage.total_amount_cents - (last_threshold_amount_cents || 0) - base_amount_cents.fdiv(next_threshold_amount_cents - (last_threshold_amount_cents || 0)) - end + attr_reader :lifetime_usage end end diff --git a/app/services/lifetime_usages/update_service.rb b/app/services/lifetime_usages/update_service.rb new file mode 100644 index 00000000000..72a9817851a --- /dev/null +++ b/app/services/lifetime_usages/update_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module LifetimeUsages + class UpdateService < BaseService + def initialize(lifetime_usage:, params:) + @lifetime_usage = lifetime_usage + @params = params + + super + end + + def call + return result.not_found_failure!(resource: 'lifetime_usage') unless lifetime_usage + + lifetime_usage.update!(historical_usage_amount_cents: params[:external_historical_usage_amount_cents]) + + result.lifetime_usage = lifetime_usage + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :lifetime_usage, :params + end +end diff --git a/app/services/lifetime_usages/usage_thresholds_completion_service.rb b/app/services/lifetime_usages/usage_thresholds_completion_service.rb new file mode 100644 index 00000000000..2894614441c --- /dev/null +++ b/app/services/lifetime_usages/usage_thresholds_completion_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module LifetimeUsages + class UsageThresholdsCompletionService < BaseService + def initialize(lifetime_usage:) + @lifetime_usage = lifetime_usage + @usage_thresholds = lifetime_usage.subscription.plan.usage_thresholds + + super + end + + def call + result.usage_thresholds = [] + return result unless usage_thresholds.any? + + largest_non_recurring_threshold_amount_cents = usage_thresholds.not_recurring.order(amount_cents: :desc).first&.amount_cents || 0 + recurring_threshold = usage_thresholds.recurring.first + + # split non-recurring thresholds into 2 groups: passed and not passed + passed_thresholds, not_passed_thresholds = usage_thresholds.not_recurring.order(amount_cents: :asc).partition do |threshold| + threshold.amount_cents <= lifetime_usage.total_amount_cents + end + + subscription_ids = organization.subscriptions + .where(external_id: subscription.external_id, subscription_at: subscription.subscription_at) + .where(canceled_at: nil) + .ids + + # add all passed thresholds to the result, completion rate is 100% + passed_thresholds.each do |threshold| + # fallback to Time.current if the invoice is not yet generated + reached_at = AppliedUsageThreshold + .where(usage_threshold: threshold) + .joins(invoice: :invoice_subscriptions) + .where(invoice_subscriptions: {subscription_id: subscription_ids}).maximum(:created_at) || Time.current + + add_usage_threshold threshold, threshold.amount_cents, 1.0, reached_at + end + + last_passed_threshold_amount = passed_thresholds.last&.amount_cents || 0 + + # If we have a not-passed threshold that means we can ignore the recurring one + # if not_passed_thresholds is empty, we need to check the recurring one. + if not_passed_thresholds.empty? + if recurring_threshold + add_recurring_threshold(recurring_threshold, last_passed_threshold_amount, subscription_ids) + end + else + threshold = not_passed_thresholds.shift + add_usage_threshold threshold, threshold.amount_cents, (lifetime_usage.total_amount_cents - last_passed_threshold_amount).fdiv(threshold.amount_cents - last_passed_threshold_amount), nil + + not_passed_thresholds.each do |threshold| + add_usage_threshold threshold, threshold.amount_cents, 0.0, nil + end + + # add recurring at the end if it's there + if recurring_threshold + add_usage_threshold recurring_threshold, largest_non_recurring_threshold_amount_cents + recurring_threshold.amount_cents, 0.0, nil + end + end + + result + end + + private + + attr_reader :lifetime_usage, :usage_thresholds + delegate :organization, :subscription, to: :lifetime_usage + + def add_usage_threshold(usage_threshold, amount_cents, completion_ratio, reached_at) + result.usage_thresholds << { + usage_threshold:, + amount_cents:, + completion_ratio:, + reached_at: + } + end + + def add_recurring_threshold(recurring_threshold, last_passed_threshold_amount, subscription_ids) + recurring_remainder = (last_passed_threshold_amount + lifetime_usage.total_amount_cents) % recurring_threshold.amount_cents + + applied_thresholds = AppliedUsageThreshold + .where(usage_threshold: recurring_threshold) + .joins(invoice: :invoice_subscriptions) + .where(invoice_subscriptions: {subscription_id: subscription_ids}) + .order(lifetime_usage_amount_cents: :asc) + + occurence = (lifetime_usage.total_amount_cents - last_passed_threshold_amount) / recurring_threshold.amount_cents + occurence.times do |i| + amount_cents = last_passed_threshold_amount + ((i + 1) * recurring_threshold.amount_cents) + reached_at = applied_thresholds.find { |applied| applied.lifetime_usage_amount_cents >= amount_cents }&.created_at || Time.current + + add_usage_threshold recurring_threshold, amount_cents, 1.0, reached_at + end + add_usage_threshold recurring_threshold, lifetime_usage.total_amount_cents - recurring_remainder + recurring_threshold.amount_cents, recurring_remainder.fdiv(recurring_threshold.amount_cents), nil + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 4984b9add8b..346acdf4087 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,7 +34,9 @@ end end - resources :subscriptions, only: %i[create update show index], param: :external_id + resources :subscriptions, only: %i[create update show index], param: :external_id do + resource :lifetime_usage, only: %i[show update] + end delete '/subscriptions/:external_id', to: 'subscriptions#terminate', as: :terminate resources :add_ons, param: :code, code: /.*/ diff --git a/schema.graphql b/schema.graphql index 42864fbe45e..288a6654c96 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6426,7 +6426,7 @@ type SubscriptionCollection { type SubscriptionLifetimeUsage { lastThresholdAmountCents: BigInt nextThresholdAmountCents: BigInt - nextTresholdRatio: Float + nextThresholdRatio: Float totalUsageAmountCents: BigInt! totalUsageFromDatetime: ISO8601DateTime! totalUsageToDatetime: ISO8601DateTime! diff --git a/schema.json b/schema.json index c63f600e217..c50a619b7cc 100644 --- a/schema.json +++ b/schema.json @@ -32565,7 +32565,7 @@ ] }, { - "name": "nextTresholdRatio", + "name": "nextThresholdRatio", "description": null, "type": { "kind": "SCALAR", diff --git a/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb b/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb index 140b666daec..190c1f40dbb 100644 --- a/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb +++ b/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb @@ -11,5 +11,5 @@ it { is_expected.to have_field(:last_threshold_amount_cents).of_type('BigInt') } it { is_expected.to have_field(:next_threshold_amount_cents).of_type('BigInt') } - it { is_expected.to have_field(:next_treshold_ratio).of_type('Float') } + it { is_expected.to have_field(:next_threshold_ratio).of_type('Float') } end diff --git a/spec/requests/api/v1/lifetime_usages_controller_spec.rb b/spec/requests/api/v1/lifetime_usages_controller_spec.rb new file mode 100644 index 00000000000..e759266339b --- /dev/null +++ b/spec/requests/api/v1/lifetime_usages_controller_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::LifetimeUsagesController, type: :request do + let(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:) } + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, organization:, subscription_at:) } + let(:subscription_at) { Date.new(2022, 8, 22) } + + before { lifetime_usage } + + describe 'show' do + it 'returns the lifetime_usage' do + get_with_token( + organization, + "/api/v1/subscriptions/#{subscription.external_id}/lifetime_usage" + ) + + expect(response).to have_http_status(:success) + expect(json[:lifetime_usage][:lago_id]).to eq(lifetime_usage.id) + end + + context 'when subscription cannot be found' do + it 'returns not found' do + get_with_token(organization, '/api/v1/subscriptions/123/lifetime_usage') + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'update' do + let(:update_params) { {external_historical_usage_amount_cents: 20} } + + it 'updates the lifetime_usage' do + put_with_token( + organization, + "/api/v1/subscriptions/#{subscription.external_id}/lifetime_usage", + {lifetime_usage: update_params} + ) + + expect(response).to have_http_status(:success) + expect(json[:lifetime_usage][:lago_id]).to eq(lifetime_usage.id) + expect(json[:lifetime_usage][:external_historical_usage_amount_cents]).to eq(20) + end + + context 'when subscription cannot be found' do + it 'returns not found' do + put_with_token(organization, '/api/v1/subscriptions/123/lifetime_usage', {lifetime_usage: update_params}) + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/serializers/v1/lifetime_usage_serializer_spec.rb b/spec/serializers/v1/lifetime_usage_serializer_spec.rb new file mode 100644 index 00000000000..33caa71e098 --- /dev/null +++ b/spec/serializers/v1/lifetime_usage_serializer_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ::V1::LifetimeUsageSerializer do + subject(:serializer) { described_class.new(lifetime_usage, root_name: 'lifetime_usage', includes: %i[usage_thresholds]) } + + let(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:, historical_usage_amount_cents:, invoiced_usage_amount_cents:, current_usage_amount_cents:) } + let(:historical_usage_amount_cents) { 15 } + let(:invoiced_usage_amount_cents) { 12 } + let(:current_usage_amount_cents) { 18 } + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + + it 'serializes the object' do + result = JSON.parse(serializer.to_json) + aggregate_failures do + expect(result['lifetime_usage']).to include( + 'lago_id' => lifetime_usage.id, + 'lago_subscription_id' => lifetime_usage.subscription.id, + 'external_subscription_id' => lifetime_usage.subscription.external_id, + 'external_historical_usage_amount_cents' => historical_usage_amount_cents, + 'invoiced_usage_amount_cents' => invoiced_usage_amount_cents, + 'current_usage_amount_cents' => current_usage_amount_cents + ) + end + end + + context "with usage_thresholds in the plan" do + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 100) } + let(:usage_threshold2) { create(:usage_threshold, plan:, amount_cents: 200) } + + let(:applied_usage_threshold) { create(:applied_usage_threshold, lifetime_usage_amount_cents: 120, usage_threshold: usage_threshold, invoice:) } + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:current_usage_amount_cents) { 120 } + + before do + usage_threshold + usage_threshold2 + invoice_subscription + applied_usage_threshold + end + + it 'serializes the usage_thresholds' do + result = JSON.parse(serializer.to_json) + aggregate_failures do + expect(result['lifetime_usage']).to include( + 'lago_id' => lifetime_usage.id, + 'usage_thresholds' => [ + {"amount_cents" => 100, "completion_ratio" => 1.0, "reached_at" => applied_usage_threshold.created_at.iso8601(3)}, + {"amount_cents" => 200, "completion_ratio" => 0.47, "reached_at" => nil} + ] + ) + end + end + end +end diff --git a/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb b/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb index ad7be24fbbf..90aa1014394 100644 --- a/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb +++ b/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb @@ -17,7 +17,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil expect(lifetime_usage_result.next_threshold_amount_cents).to be_nil - expect(lifetime_usage_result.next_treshold_ratio).to be_nil + expect(lifetime_usage_result.next_threshold_ratio).to be_nil end context 'with a usage_threshold' do @@ -28,7 +28,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil expect(lifetime_usage_result.next_threshold_amount_cents).to eq(100) - expect(lifetime_usage_result.next_treshold_ratio).to be_zero + expect(lifetime_usage_result.next_threshold_ratio).to be_zero end context 'with a lifetime_usage' do @@ -37,7 +37,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil expect(lifetime_usage_result.next_threshold_amount_cents).to eq(100) - expect(lifetime_usage_result.next_treshold_ratio).to eq(0.23) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.23) end end end @@ -64,7 +64,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to eq(100) expect(lifetime_usage_result.next_threshold_amount_cents).to eq(200) - expect(lifetime_usage_result.next_treshold_ratio).to eq(0.2) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.2) end context 'when lifetime_usage is above last threshold' do @@ -74,7 +74,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to eq(200) expect(lifetime_usage_result.next_threshold_amount_cents).to be_nil - expect(lifetime_usage_result.next_treshold_ratio).to be_nil + expect(lifetime_usage_result.next_threshold_ratio).to be_nil end end @@ -84,7 +84,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to eq(100) expect(lifetime_usage_result.next_threshold_amount_cents).to eq(300) - expect(lifetime_usage_result.next_treshold_ratio).to eq(0.1) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.1) end context 'when lifetime_usage is above next threshold' do @@ -94,7 +94,7 @@ it 'computes the amounts' do expect(lifetime_usage_result.last_threshold_amount_cents).to eq(700) expect(lifetime_usage_result.next_threshold_amount_cents).to eq(900) - expect(lifetime_usage_result.next_treshold_ratio).to eq(0.115) # (723 - 700) / 200 + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.115) # (723 - 700) / 200 end end end diff --git a/spec/services/lifetime_usages/update_service_spec.rb b/spec/services/lifetime_usages/update_service_spec.rb new file mode 100644 index 00000000000..dace873c1a1 --- /dev/null +++ b/spec/services/lifetime_usages/update_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LifetimeUsages::UpdateService, type: :service do + subject(:update_service) { described_class.new(lifetime_usage:, params:) } + + let(:lifetime_usage) { create(:lifetime_usage) } + let(:params) do + { + external_historical_usage_amount_cents: + } + end + let(:external_historical_usage_amount_cents) { 20 } + + describe "#call" do + it "updates the historical usage" do + result = update_service.call + expect(result).to be_success + + expect(result.lifetime_usage.historical_usage_amount_cents).to eq(20) + end + + context "without lifetime_usage" do + let(:lifetime_usage) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq('lifetime_usage_not_found') + end + end + + context "with a negative historical usage amount" do + let(:external_historical_usage_amount_cents) { -20 } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages[:historical_usage_amount_cents]).to eq(['value_is_out_of_range']) + end + end + end +end diff --git a/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb b/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb new file mode 100644 index 00000000000..698d7ca3542 --- /dev/null +++ b/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LifetimeUsages::UsageThresholdsCompletionService, type: :service do + subject(:result) { described_class.call(lifetime_usage:) } + + let(:lifetime_usage) { create(:lifetime_usage, subscription:, organization:, current_usage_amount_cents:) } + let(:current_usage_amount_cents) { 0 } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + + it 'computes the usage thresholds' do + expect(result.usage_thresholds).to be_empty + end + + context "with a usage threshold" do + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 100) } + + before do + usage_threshold + end + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(1) + threshold = thresholds.first + + expect(threshold[:usage_threshold]).to eq(usage_threshold) + expect(threshold[:amount_cents]).to eq(usage_threshold.amount_cents) + expect(threshold[:completion_ratio]).to be_zero + expect(threshold[:reached_at]).to be_nil + end + + context 'with a lifetime_usage' do + let(:current_usage_amount_cents) { 23 } + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(1) + threshold = thresholds.first + + expect(threshold[:usage_threshold]).to eq(usage_threshold) + expect(threshold[:amount_cents]).to eq(usage_threshold.amount_cents) + expect(threshold[:completion_ratio]).to eq(0.23) + expect(threshold[:reached_at]).to be_nil + end + end + end + + context 'with a past threshold' do + let(:usage_threshold1) { create(:usage_threshold, plan:, amount_cents: 100) } + let(:usage_threshold2) { create(:usage_threshold, plan:, amount_cents: 200) } + + let(:applied_usage_threshold) { create(:applied_usage_threshold, usage_threshold: usage_threshold1, invoice:) } + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:current_usage_amount_cents) { 120 } + + before do + usage_threshold1 + usage_threshold2 + + invoice_subscription + applied_usage_threshold + end + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents) + expect(threshold2[:completion_ratio]).to eq(0.2) + expect(threshold2[:reached_at]).to be_nil + end + + context 'when lifetime_usage is above last threshold' do + let(:applied_usage_threshold2) { create(:applied_usage_threshold, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 223 } + + before do + applied_usage_threshold2 + end + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents) + expect(threshold2[:completion_ratio]).to eq(1) + expect(threshold2[:reached_at]).to eq(applied_usage_threshold2.created_at) + end + end + + context 'when next threshold is recurring' do + let(:usage_threshold2) { create(:usage_threshold, :recurring, plan:, amount_cents: 200) } + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents + usage_threshold1.amount_cents) + expect(threshold2[:completion_ratio]).to eq(0.1) # 20/200 + expect(threshold2[:reached_at]).to be_nil + end + + context 'when lifetime_usage is above next threshold' do + let(:applied_usage_threshold2) { create(:applied_usage_threshold, lifetime_usage_amount_cents: 700, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 723 } + + before do + applied_usage_threshold2 + end + + it 'computes the usage thresholds' do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(5) + threshold1 = thresholds.shift + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + last_threshold = thresholds.pop + + thresholds.each.with_index do |threshold, index| + expect(threshold[:usage_threshold]).to eq(usage_threshold2) + expect(threshold[:amount_cents]).to eq(100 + (index + 1) * 200) + expect(threshold[:completion_ratio]).to eq(1.0) + expect(threshold[:reached_at]).to eq(applied_usage_threshold2.created_at) + end + + expect(last_threshold[:usage_threshold]).to eq(usage_threshold2) + expect(last_threshold[:amount_cents]).to eq(900) + expect(last_threshold[:completion_ratio]).to eq(0.115) + expect(last_threshold[:reached_at]).to be_nil + end + end + end + end +end