Skip to content

Commit

Permalink
feat(ProgressiveBilling) - Add LifetimeUsages::UsageThresholdsComplet…
Browse files Browse the repository at this point in the history
…ionService (#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.
  • Loading branch information
nudded authored Sep 5, 2024
1 parent 4b68614 commit 5daab1c
Show file tree
Hide file tree
Showing 15 changed files with 567 additions and 78 deletions.
48 changes: 48 additions & 0 deletions app/controllers/api/v1/lifetime_usages_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions app/graphql/types/subscriptions/lifetime_usage_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions app/serializers/v1/lifetime_usage_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions app/services/lifetime_usages/update_service.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: /.*/
Expand Down
2 changes: 1 addition & 1 deletion schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 5daab1c

Please sign in to comment.