-
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): create an invoice for a threshold (#2413)
## 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 adds the services to create an invoice with a fee when a specific `usage_threshold` is reach by a subscription. It creates: - A `progressive_billing` invoice - An `invoice_subscription` with the right period boundaries - A `progressive_billing` fee It also handles: - PDF generation - Tax computation - Email delivery - Tracking on Segment - Payment - Sync with integrations
- Loading branch information
1 parent
8a5b5c7
commit e30357d
Showing
18 changed files
with
677 additions
and
10 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
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
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,59 @@ | ||
# frozen_string_literal: true | ||
|
||
module Fees | ||
class CreateFromUsageThresholdService < BaseService | ||
def initialize(usage_threshold:, invoice:, amount_cents:) | ||
@usage_threshold = usage_threshold | ||
@invoice = invoice | ||
@amount_cents = amount_cents | ||
|
||
super | ||
end | ||
|
||
def call | ||
fee = Fee.new( | ||
subscription: invoice.subscriptions.first, | ||
invoice:, | ||
usage_threshold:, | ||
invoice_display_name: usage_threshold.threshold_display_name, | ||
invoiceable: usage_threshold, | ||
amount_cents: amount_cents, | ||
amount_currency: invoice.currency, | ||
fee_type: :progressive_billing, | ||
units:, | ||
unit_amount_cents: unit_amount_cents, | ||
payment_status: :pending, | ||
taxes_amount_cents: 0, | ||
properties: { | ||
charges_from_datetime: invoice.invoice_subscriptions.first.charges_from_datetime, | ||
charges_to_datetime: invoice.invoice_subscriptions.first.charges_to_datetime, | ||
timestamp: invoice.invoice_subscriptions.first.timestamp | ||
} | ||
) | ||
|
||
taxes_result = Fees::ApplyTaxesService.call(fee:) | ||
taxes_result.raise_if_error! | ||
|
||
fee.save! | ||
result.fee = fee | ||
|
||
result | ||
end | ||
|
||
private | ||
|
||
attr_reader :usage_threshold, :invoice, :amount_cents | ||
|
||
def units | ||
return 1 unless usage_threshold.recurring? | ||
|
||
amount_cents.fdiv(usage_threshold.amount_cents) | ||
end | ||
|
||
def unit_amount_cents | ||
return amount_cents unless usage_threshold.recurring? | ||
|
||
usage_threshold.amount_cents | ||
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,110 @@ | ||
# frozen_string_literal: true | ||
|
||
module Invoices | ||
class ProgressiveBillingService < BaseService | ||
def initialize(usage_thresholds:, lifetime_usage:, timestamp: Time.current) | ||
@usage_thresholds = usage_thresholds | ||
@lifetime_usage = lifetime_usage | ||
@timestamp = timestamp | ||
|
||
super | ||
end | ||
|
||
def call | ||
ActiveRecord::Base.transaction do | ||
create_generating_invoice | ||
create_threshold_fees | ||
Invoices::ComputeAmountsFromFees.call(invoice:) | ||
invoice.finalized! | ||
end | ||
|
||
Utils::SegmentTrack.invoice_created(invoice) | ||
SendWebhookJob.perform_later('invoice.created', invoice) | ||
GeneratePdfAndNotifyJob.perform_later(invoice:, email: should_deliver_email?) | ||
Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? | ||
Integrations::Aggregator::SalesOrders::CreateJob.perform_later(invoice:) if invoice.should_sync_sales_order? | ||
Invoices::Payments::CreateService.call(invoice) | ||
|
||
result.invoice = invoice | ||
result | ||
rescue ActiveRecord::RecordInvalid => e | ||
result.record_validation_failure!(record: e.record) | ||
rescue Sequenced::SequenceError | ||
raise | ||
rescue => e | ||
result.fail_with_error!(e) | ||
end | ||
|
||
private | ||
|
||
attr_accessor :usage_thresholds, :lifetime_usage, :timestamp, :invoice | ||
|
||
delegate :subscription, to: :lifetime_usage | ||
|
||
def create_generating_invoice | ||
invoice_result = CreateGeneratingService.call( | ||
customer: subscription.customer, | ||
invoice_type: :progressive_billing, | ||
currency: usage_thresholds.first.plan.amount_currency, | ||
datetime: Time.zone.at(timestamp) | ||
) do |invoice| | ||
CreateInvoiceSubscriptionService | ||
.call(invoice:, subscriptions: [subscription], timestamp:, invoicing_reason: :progressive_billing) | ||
.raise_if_error! | ||
end | ||
invoice_result.raise_if_error! | ||
|
||
@invoice = invoice_result.invoice | ||
end | ||
|
||
def sorted_thresholds | ||
fixed = usage_thresholds.select { |t| !t.recurring }.sort_by(&:amount_cents) | ||
recurring = usage_thresholds.select(&:recurring) | ||
fixed + recurring | ||
end | ||
|
||
def create_threshold_fees | ||
sorted_thresholds.each do |usage_threshold| | ||
fee_result = Fees::CreateFromUsageThresholdService | ||
.call(usage_threshold:, invoice:, amount_cents: amount_cents(usage_threshold)) | ||
fee_result.raise_if_error! | ||
fee_result.fee | ||
end | ||
end | ||
|
||
def should_deliver_email? | ||
License.premium? && subscription.organization.email_settings.include?('invoice.finalized') | ||
end | ||
|
||
def amount_cents(usage_threshold) | ||
if usage_threshold.recurring? | ||
# NOTE: Recurring is always the last threshold. | ||
# Amount is the current lifetime usage without already invoiced thresholds | ||
# The recurring threshold can be reached multiple time, so we need to compute the number of times | ||
units = (total_lifetime_usage_amount_cents - invoiced_amount_cents) / usage_threshold.amount_cents | ||
units * usage_threshold.amount_cents | ||
else | ||
# NOTE: Amount to bill if the current threshold minus the usage that have already been invoiced | ||
result_amount = usage_threshold.amount_cents - invoiced_amount_cents | ||
|
||
# NOTE: Add the amount to the invoiced_amount_cents for next non recurring threshold | ||
@invoiced_amount_cents += result_amount | ||
|
||
result_amount | ||
end | ||
end | ||
|
||
# NOTE: Sum of usage that have already been invoiced | ||
def invoiced_amount_cents | ||
@invoiced_amount_cents ||= subscription.invoices | ||
.finalized | ||
.where(invoice_type: %w[subscription progressive_billing]) | ||
.sum(:fees_amount_cents) | ||
end | ||
|
||
# NOTE: Current lifetime usage amount | ||
def total_lifetime_usage_amount_cents | ||
@total_lifetime_usage_amount_cents ||= lifetime_usage.invoiced_usage_amount_cents + lifetime_usage.current_usage_amount_cents | ||
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
# frozen_String_literal: true | ||
|
||
class AddUsageThresholdIdToFees < ActiveRecord::Migration[7.1] | ||
def change | ||
add_reference :fees, :usage_threshold, type: :uuid, foreign_key: true, index: true | ||
end | ||
end |
14 changes: 14 additions & 0 deletions
14
db/migrate/20240813121307_add_progressive_billing_to_invoicing_reason.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,14 @@ | ||
# frozen_string_literal: true | ||
|
||
class AddProgressiveBillingToInvoicingReason < ActiveRecord::Migration[7.1] | ||
disable_ddl_transaction! | ||
|
||
def up | ||
execute <<-SQL | ||
ALTER TYPE subscription_invoicing_reason ADD VALUE IF NOT EXISTS 'progressive_billing'; | ||
SQL | ||
end | ||
|
||
def down | ||
end | ||
end |
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.
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
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.