Skip to content

Commit

Permalink
feat(ProgressiveBilling): create an invoice for a threshold (#2413)
Browse files Browse the repository at this point in the history
## 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
vincent-pochet authored Aug 19, 2024
1 parent 8a5b5c7 commit e30357d
Show file tree
Hide file tree
Showing 18 changed files with 677 additions and 10 deletions.
3 changes: 2 additions & 1 deletion app/models/fee.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Fee < ApplicationRecord
belongs_to :subscription, optional: true
belongs_to :charge_filter, -> { with_discarded }, optional: true
belongs_to :group, -> { with_discarded }, optional: true
belongs_to :usage_threshold, -> { with_discarded }, optional: true
belongs_to :invoiceable, polymorphic: true, optional: true
belongs_to :true_up_parent_fee, class_name: 'Fee', optional: true

Expand All @@ -34,7 +35,7 @@ class Fee < ApplicationRecord
monetize :unit_amount_cents, disable_validation: true, allow_nil: true, with_model_currency: :currency

# TODO: Deprecate add_on type in the near future
FEE_TYPES = %i[charge add_on subscription credit commitment].freeze
FEE_TYPES = %i[charge add_on subscription credit commitment progressive_billing].freeze
PAYMENT_STATUS = %i[pending succeeded failed refunded].freeze

enum fee_type: FEE_TYPES
Expand Down
2 changes: 1 addition & 1 deletion app/models/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Invoice < ApplicationRecord
allow_nil: true,
with_model_currency: :currency

INVOICE_TYPES = %i[subscription add_on credit one_off advance_charges].freeze
INVOICE_TYPES = %i[subscription add_on credit one_off advance_charges progressive_billing].freeze
PAYMENT_STATUS = %i[pending succeeded failed].freeze

VISIBLE_STATUS = {draft: 0, finalized: 1, voided: 2, failed: 4}.freeze
Expand Down
3 changes: 2 additions & 1 deletion app/models/invoice_subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class InvoiceSubscription < ApplicationRecord
subscription_periodic: 'subscription_periodic',
subscription_terminating: 'subscription_terminating',
in_advance_charge: 'in_advance_charge',
in_advance_charge_periodic: 'in_advance_charge_periodic'
in_advance_charge_periodic: 'in_advance_charge_periodic',
progressive_billing: 'progressive_billing'
}.freeze

enum invoicing_reason: INVOICING_REASONS
Expand Down
2 changes: 1 addition & 1 deletion app/services/fees/apply_taxes_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def applicable_taxes
return fee.add_on.taxes if fee.add_on? && fee.add_on.taxes.any?
return fee.charge.taxes if fee.charge? && fee.charge.taxes.any?
return fee.invoiceable.taxes if fee.commitment? && fee.invoiceable.taxes.any?
if (fee.charge? || fee.subscription? || fee.commitment?) && fee.subscription.plan.taxes.any?
if (fee.charge? || fee.subscription? || fee.commitment? || fee.progressive_billing?) && fee.subscription.plan.taxes.any?
return fee.subscription.plan.taxes
end
return customer.taxes if customer.taxes.any?
Expand Down
59 changes: 59 additions & 0 deletions app/services/fees/create_from_usage_threshold_service.rb
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
9 changes: 4 additions & 5 deletions app/services/invoices/create_invoice_subscription_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,10 @@ def calculate_boundaries(subscription)
end

def date_service(subscription)
Subscriptions::DatesService.new_instance(
subscription,
datetime,
current_usage: subscription.terminated? && subscription.upgraded?
)
current_usage = invoicing_reason.to_sym == :progressive_billing
current_usage ||= subscription.terminated? && subscription.upgraded?

Subscriptions::DatesService.new_instance(subscription, datetime, current_usage:)
end

# This method calculates boundaries for terminated subscription. If termination is happening on billing date
Expand Down
110 changes: 110 additions & 0 deletions app/services/invoices/progressive_billing_service.rb
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
7 changes: 7 additions & 0 deletions db/migrate/20240813095718_add_usage_threshold_id_to_fees.rb
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
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
5 changes: 4 additions & 1 deletion db/schema.rb

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

2 changes: 2 additions & 0 deletions schema.graphql

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

12 changes: 12 additions & 0 deletions schema.json

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

14 changes: 14 additions & 0 deletions spec/factories/fees.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,18 @@
invoiceable_type { 'Commitment' }
invoiceable_id { commitment.id }
end

factory :progressive_billing_fee, class: 'Fee' do
invoice
fee_type { 'progressive_billing' }
subscription

amount_cents { 200 }
amount_currency { 'EUR' }
taxes_amount_cents { 2 }

usage_threshold { create(:usage_threshold, plan: subscription.plan) }
invoiceable_type { 'UsageThreshold' }
invoiceable_id { usage_threshold.id }
end
end
6 changes: 6 additions & 0 deletions spec/models/fee_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
RSpec.describe Fee, type: :model do
subject(:fee_model) { described_class }

describe 'associations' do
subject(:fee) { build(:fee) }

it { is_expected.to belong_to(:usage_threshold).optional }
end

describe '.item_code' do
context 'when it is a subscription fee' do
let(:subscription) { create(:subscription) }
Expand Down
Loading

0 comments on commit e30357d

Please sign in to comment.