-
Notifications
You must be signed in to change notification settings - Fork 100
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ProgressiveBilling) - Add LifetimeUsages::UsageThresholdsComplet…
…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
Showing
15 changed files
with
567 additions
and
78 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
98 changes: 98 additions & 0 deletions
98
app/services/lifetime_usages/usage_thresholds_completion_service.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.