From 0b9395b514a981489c79306ccf95794aab9024f6 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Tue, 20 Aug 2024 17:09:44 +0200 Subject: [PATCH] Implement Credits::ProgressiveBillingService and add it to Invoices::CalculateFeesService (#2443) ## 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 Adds a service for calculating how much to deduct from the subscription invoice based on previously billed progressive invoices. --- app/models/invoice.rb | 73 ++--- .../credits/progressive_billing_service.rb | 69 +++++ .../invoices/calculate_fees_service.rb | 1 + .../recalculate_and_check_service.rb | 4 +- ...billing_credit_amount_cents_to_invoices.rb | 7 + db/schema.rb | 3 +- .../progressive_billing_service_spec.rb | 251 ++++++++++++++++++ .../invoices/calculate_fees_service_spec.rb | 7 + 8 files changed, 377 insertions(+), 38 deletions(-) create mode 100644 app/services/credits/progressive_billing_service.rb create mode 100644 db/migrate/20240820125840_add_progressive_billing_credit_amount_cents_to_invoices.rb create mode 100644 spec/services/credits/progressive_billing_service_spec.rb diff --git a/app/models/invoice.rb b/app/models/invoice.rb index e1fbdc65ca7..5cf7f0d940f 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -403,42 +403,43 @@ def status_changed_to_finalized? # # Table name: invoices # -# id :uuid not null, primary key -# coupons_amount_cents :bigint default(0), not null -# credit_notes_amount_cents :bigint default(0), not null -# currency :string -# fees_amount_cents :bigint default(0), not null -# file :string -# invoice_type :integer default("subscription"), not null -# issuing_date :date -# negative_amount_cents :bigint default(0), not null -# net_payment_term :integer default(0), not null -# number :string default(""), not null -# payment_attempts :integer default(0), not null -# payment_dispute_lost_at :datetime -# payment_due_date :date -# payment_overdue :boolean default(FALSE) -# payment_status :integer default("pending"), not null -# prepaid_credit_amount_cents :bigint default(0), not null -# ready_for_payment_processing :boolean default(TRUE), not null -# ready_to_be_refreshed :boolean default(FALSE), not null -# skip_charges :boolean default(FALSE), not null -# status :integer default("finalized"), not null -# sub_total_excluding_taxes_amount_cents :bigint default(0), not null -# sub_total_including_taxes_amount_cents :bigint default(0), not null -# taxes_amount_cents :bigint default(0), not null -# taxes_rate :float default(0.0), not null -# timezone :string default("UTC"), not null -# total_amount_cents :bigint default(0), not null -# version_number :integer default(4), not null -# voided_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# customer_id :uuid -# organization_id :uuid not null -# organization_sequential_id :integer default(0), not null -# payable_group_id :uuid -# sequential_id :integer +# id :uuid not null, primary key +# coupons_amount_cents :bigint default(0), not null +# credit_notes_amount_cents :bigint default(0), not null +# currency :string +# fees_amount_cents :bigint default(0), not null +# file :string +# invoice_type :integer default("subscription"), not null +# issuing_date :date +# negative_amount_cents :bigint default(0), not null +# net_payment_term :integer default(0), not null +# number :string default(""), not null +# payment_attempts :integer default(0), not null +# payment_dispute_lost_at :datetime +# payment_due_date :date +# payment_overdue :boolean default(FALSE) +# payment_status :integer default("pending"), not null +# prepaid_credit_amount_cents :bigint default(0), not null +# progressive_billing_credit_amount_cents :bigint default(0), not null +# ready_for_payment_processing :boolean default(TRUE), not null +# ready_to_be_refreshed :boolean default(FALSE), not null +# skip_charges :boolean default(FALSE), not null +# status :integer default("finalized"), not null +# sub_total_excluding_taxes_amount_cents :bigint default(0), not null +# sub_total_including_taxes_amount_cents :bigint default(0), not null +# taxes_amount_cents :bigint default(0), not null +# taxes_rate :float default(0.0), not null +# timezone :string default("UTC"), not null +# total_amount_cents :bigint default(0), not null +# version_number :integer default(4), not null +# voided_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid +# organization_id :uuid not null +# organization_sequential_id :integer default(0), not null +# payable_group_id :uuid +# sequential_id :integer # # Indexes # diff --git a/app/services/credits/progressive_billing_service.rb b/app/services/credits/progressive_billing_service.rb new file mode 100644 index 00000000000..df19bdf8647 --- /dev/null +++ b/app/services/credits/progressive_billing_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Credits + class ProgressiveBillingService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + result.credits = [] + return result unless should_create_progressive_billing_credit? + + invoice.invoice_subscriptions.each do |invoice_subscription| + subscription = invoice_subscription.subscription + progressive_billing_invoices = subscription + .invoices + .progressive_billing + .finalized + .where(issuing_date: invoice_subscription.charges_from_datetime...invoice_subscription.charges_to_datetime) + .order(issuing_date: :asc) + + total_subscription_amount = invoice.fees.charge.where(subscription: subscription).sum(:amount_cents) + + remaining_to_credit = total_subscription_amount + + progressive_billing_invoices.each do |progressive_billing_invoice| + amount_to_credit = progressive_billing_invoice.fees.progressive_billing.sum(:amount_cents) + + if amount_to_credit > remaining_to_credit + # TODO: create credit note for (amount_to_credit - remaining_credit) + invoice.negative_amount_cents -= (amount_to_credit - remaining_to_credit) + amount_to_credit = remaining_to_credit + end + + if amount_to_credit.positive? + credit = Credit.create!( + invoice:, + progressive_billing_invoice:, + amount_cents: amount_to_credit, + amount_currency: invoice.currency, + before_taxes: true + ) + + invoice.sub_total_excluding_taxes_amount_cents -= credit.amount_cents + invoice.progressive_billing_credit_amount_cents += credit.amount_cents + result.credits << credit + + remaining_to_credit -= amount_to_credit + end + end + end + result + end + + private + + def should_create_progressive_billing_credit? + invoice.invoice_subscriptions.any? do |invoice_subscription| + invoice_subscription.subscription.invoices.progressive_billing + .finalized + .where(issuing_date: invoice_subscription.charges_from_datetime...invoice_subscription.charges_to_datetime) + .exists? + end + end + + attr_reader :invoice + end +end diff --git a/app/services/invoices/calculate_fees_service.rb b/app/services/invoices/calculate_fees_service.rb index 58d360f1767..9c2450b11f9 100644 --- a/app/services/invoices/calculate_fees_service.rb +++ b/app/services/invoices/calculate_fees_service.rb @@ -44,6 +44,7 @@ def call invoice.sub_total_excluding_taxes_amount_cents = invoice.fees.sum(:amount_cents) - invoice.coupons_amount_cents + Credits::ProgressiveBillingService.call(invoice:) Credits::AppliedCouponsService.call(invoice:) if should_create_coupon_credit? Invoices::ComputeAmountsFromFees.call(invoice:) diff --git a/app/services/lifetime_usages/recalculate_and_check_service.rb b/app/services/lifetime_usages/recalculate_and_check_service.rb index 85df89340e3..bedc4f0db24 100644 --- a/app/services/lifetime_usages/recalculate_and_check_service.rb +++ b/app/services/lifetime_usages/recalculate_and_check_service.rb @@ -16,8 +16,10 @@ def call usage_thresholds.each do |usage_threshold| SendWebhookJob.perform_later('subscription.usage_threshold_reached', subscription, usage_threshold:) end - Invoices::ProgressiveBillingService.call(usage_thresholds:, lifetime_usage:).raise_if_error! + invoice_result = Invoices::ProgressiveBillingService.call(usage_thresholds:, lifetime_usage:).raise_if_error! + result.invoice = invoice_result.invoice end + result end private diff --git a/db/migrate/20240820125840_add_progressive_billing_credit_amount_cents_to_invoices.rb b/db/migrate/20240820125840_add_progressive_billing_credit_amount_cents_to_invoices.rb new file mode 100644 index 00000000000..57adb00c7de --- /dev/null +++ b/db/migrate/20240820125840_add_progressive_billing_credit_amount_cents_to_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddProgressiveBillingCreditAmountCentsToInvoices < ActiveRecord::Migration[7.1] + def change + add_column :invoices, :progressive_billing_credit_amount_cents, :bigint, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index e8e845d5351..cb19b2862ab 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_20_090312) do +ActiveRecord::Schema[7.1].define(version: 2024_08_20_125840) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -757,6 +757,7 @@ t.boolean "payment_overdue", default: false t.uuid "payable_group_id" t.bigint "negative_amount_cents", default: 0, null: false + t.bigint "progressive_billing_credit_amount_cents", default: 0, null: false t.index ["customer_id", "sequential_id"], name: "index_invoices_on_customer_id_and_sequential_id", unique: true t.index ["customer_id"], name: "index_invoices_on_customer_id" t.index ["number"], name: "index_invoices_on_number" diff --git a/spec/services/credits/progressive_billing_service_spec.rb b/spec/services/credits/progressive_billing_service_spec.rb new file mode 100644 index 00000000000..947d4d43f24 --- /dev/null +++ b/spec/services/credits/progressive_billing_service_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require 'rails_helper' + +Rspec.describe Credits::ProgressiveBillingService, type: :service do + subject(:credit_service) { described_class.new(invoice:) } + + let(:subscription) { create(:subscription, customer_id: customer.id) } + let(:organization) { subscription.organization } + let(:customer) { create(:customer) } + let(:subscriptions) { [subscription] } + + let(:invoice) do + create(:invoice, + :subscription, + customer:, + organization:, + sub_total_excluding_taxes_amount_cents: 1000, + subscriptions: subscriptions) + end + + before do + invoice + invoice.invoice_subscriptions.each { |is| is.update!(charges_from_datetime: invoice.issuing_date - 1.month, charges_to_datetime: invoice.issuing_date) } + subscription_fees + end + + let(:subscription_fees) { [subscription_fee1, subscription_fee2] } + let(:subscription_fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 500) } + let(:subscription_fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 500) } + + context "without progressive billing invoices" do + describe "#call" do + it "does not apply any credit to the invoice" do + result = credit_service.call + expect(result.credits).to be_empty + expect(invoice.progressive_billing_credit_amount_cents).to be_zero + end + end + end + + context "with one progressive billing invoice for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day + ) + end + + let(:progressive_billing_fee) { create(:progressive_billing_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + + before do + progressive_billing_invoice + progressive_billing_fee + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(20) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + end + end + end + + context "with multiple progressive billing invoices for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.days + ) + end + + let(:progressive_billing_invoice2) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day + ) + end + + let(:progressive_billing_fee) { create(:progressive_billing_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + let(:progressive_billing_fee2) { create(:progressive_billing_fee, amount_cents: 200, invoice: progressive_billing_invoice2) } + + before do + progressive_billing_fee + progressive_billing_fee2 + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(2) + first_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice } + expect(first_credit.amount_cents).to eq(20) + + first_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice2 } + expect(first_credit.amount_cents).to eq(200) + + expect(invoice.progressive_billing_credit_amount_cents).to eq(220) + end + end + end + + context "with multiple progressive billing invoices for the sole subscription with an amount higher than the subscription charges" do + let(:progressive_billing_invoice) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 3.days + ) + end + + let(:progressive_billing_invoice2) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.days + ) + end + + let(:progressive_billing_invoice3) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day + ) + end + + let(:progressive_billing_fee) { create(:progressive_billing_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + let(:progressive_billing_fee2) { create(:progressive_billing_fee, amount_cents: 1000, invoice: progressive_billing_invoice2) } + let(:progressive_billing_fee3) { create(:progressive_billing_fee, amount_cents: 200, invoice: progressive_billing_invoice3) } + + before do + progressive_billing_fee + progressive_billing_fee2 + progressive_billing_fee3 + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(2) + first_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice } + expect(first_credit.amount_cents).to eq(20) + + first_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice2 } + expect(first_credit.amount_cents).to eq(980) + + expect(invoice.progressive_billing_credit_amount_cents).to eq(1000) + expect(invoice.negative_amount_cents).to eq(-220) + end + end + end + + context "with one progressive billing invoice for one subscription and one without" do + let(:subscription2) { create(:subscription, customer_id: customer.id) } + let(:subscriptions) { [subscription, subscription2] } + + let(:subscription_fees) { [subscription_fee1, subscription_fee2, subscription2_fee1, subscription2_fee2] } + let(:subscription2_fee1) { create(:charge_fee, invoice:, subscription: subscription2, amount_cents: 500) } + let(:subscription2_fee2) { create(:charge_fee, invoice:, subscription: subscription2, amount_cents: 500) } + + let(:progressive_billing_invoice) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day + ) + end + + let(:progressive_billing_fee) { create(:progressive_billing_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + + before do + progressive_billing_invoice + progressive_billing_fee + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(20) + expect(credit.progressive_billing_invoice).to eq(progressive_billing_invoice) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + end + end + end + + context "with one progressive billing invoice outside the current billing boundaries for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + organization:, + customer:, + status: 'finalized', + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.months + ) + end + + let(:progressive_billing_fee) { create(:progressive_billing_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + + before do + progressive_billing_invoice + progressive_billing_fee + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits).to be_empty + expect(invoice.progressive_billing_credit_amount_cents).to eq(0) + end + end + end +end diff --git a/spec/services/invoices/calculate_fees_service_spec.rb b/spec/services/invoices/calculate_fees_service_spec.rb index f6f373b8805..31e6a6bdab8 100644 --- a/spec/services/invoices/calculate_fees_service_spec.rb +++ b/spec/services/invoices/calculate_fees_service_spec.rb @@ -85,6 +85,7 @@ allow(SegmentTrackJob).to receive(:perform_later) allow(Invoices::Payments::StripeCreateJob).to receive(:perform_later).and_call_original allow(Invoices::Payments::GocardlessCreateJob).to receive(:perform_later).and_call_original + allow(Credits::ProgressiveBillingService).to receive(:call).and_call_original end describe '#call' do @@ -113,6 +114,12 @@ end end + it "calls the ProgressiveBillingService" do + result = invoice_service.call + expect(result).to be_success + expect(Credits::ProgressiveBillingService).to have_received(:call).with(invoice:) + end + context 'when charge is pay_in_advance, not recurring and invoiceable' do let(:charge) do create(