Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions app/services/lifetime_usages/calculate_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ def call
return result
end

if lifetime_usage.recalculate_invoiced_usage
# The open-period pay-in-advance amount is subtracted from the current usage, so the invoiced
# side must be refreshed in the same run: otherwise the subtraction removes fees that the
# cached invoiced column does not contain yet, and both counters stop describing one snapshot.
pay_in_advance_amount_cents = pay_in_advance_invoiced_amount_cents
if lifetime_usage.recalculate_invoiced_usage || pay_in_advance_amount_cents.positive?
lifetime_usage.invoiced_usage_amount_cents = calculate_invoiced_usage_amount_cents
lifetime_usage.recalculate_invoiced_usage = false
lifetime_usage.invoiced_usage_amount_refreshed_at = Time.current
Expand All @@ -44,15 +48,29 @@ def calculate_invoiced_usage_amount_cents
.where(canceled_at: nil)
.select(:id)

invoices = organization.invoices.subscription
invoice_ids = organization.invoices.subscription
.where(status: %i[finalized draft])
.joins(:invoice_subscriptions)
.where(invoice_subscriptions: {subscription_id: subscription_ids})
invoices.sum { |invoice| invoice.fees.charge.sum(:amount_cents) }
.select(:id)

organization.fees.charge.where(invoice_id: invoice_ids).sum(:amount_cents)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is worth a line in the description, because it is not only a faster version of the old query: it also returns a different number in one case. The old code summed a relation joined to invoice_subscriptions, so an invoice covering two subscriptions of the same family, which is what an upgrade produces, had its fees counted twice. On a single fee of 500, main reports 1000 and this branch reports 500.

The new number is the correct one, but it changes lifetime totals and threshold timing for upgraded subscriptions, so I would mention it and add a spec for it. Otherwise someone will fold the subquery back into a join later and quietly bring the double count back.

end

def calculate_current_usage_amount_cents
current_usage.amount_cents
[current_usage.amount_cents - pay_in_advance_invoiced_amount_cents, 0].max
end

# Usage of an invoiceable pay-in-advance charge is invoiced immediately, inside the still-open
# period, so it lands in the invoiced counter while the current usage still reports it. The fee
# carries the open-period boundaries in its properties, the invoice_subscription does not.
def pay_in_advance_invoiced_amount_cents
@pay_in_advance_invoiced_amount_cents ||= organization.fees.charge
.where(subscription_id: subscription.id, pay_in_advance: true)
.joins(:invoice)
.merge(Invoice.subscription.where(status: %i[finalized draft]))
.where("(fees.properties->>'charges_to_datetime')::timestamptz > ?", Time.current)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This query now runs on every recalculation for every subscription, including plans that have no pay-in-advance charge at all. The condition on properties->>'charges_to_datetime' cannot use an index, so Postgres has to read all of the subscription's pay-in-advance fees and convert the stored date on each one, and for an invoiceable pay-in-advance charge that set grows by one fee per event and never shrinks. The recalculation runs inline on subscription activity, so the busiest subscriptions pay this most often.

A fee whose period ends in the future can only have been created during the current period, so adding fees.created_at >= <current period start> next to the existing condition keeps the same meaning while letting the database skip everything older. Could you run an EXPLAIN for a subscription with a large fee history before we merge, so we know whether that is needed now or later?

.sum(:amount_cents)
end

def current_usage
Expand Down
79 changes: 79 additions & 0 deletions lib/tasks/migrations/backfill_pay_in_advance_lifetime_usages.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# frozen_string_literal: true

# Flags `recalculate_invoiced_usage` on lifetime usages whose subscription has at least one
# immediate pay-in-advance charge invoice, so that `LifetimeUsages::CalculateService` recomputes
# them with the de-duplicated current usage.
#
# Subscriptions with ongoing activity self-heal on the next recalculation, but a dormant one keeps
# serving the doubled total forever, because the current usage is only refreshed when something
# happens on the subscription. Setting the flag makes Clock::RefreshLifetimeUsagesJob pick them up.
#
# Terminated subscriptions will not self-heal even with the flag set: CalculateService clears both
# flags without recalculating when the subscription is not active.
#
# Usage:
# # 1. Preview for a single org (no writes):
# lago exec api bundle exec rails migrations:backfill_pay_in_advance_lifetime_usages \
# DRY_RUN=true ORGANIZATION_ID=<uuid>
#
# # 2. Apply for that org:
# lago exec api bundle exec rails migrations:backfill_pay_in_advance_lifetime_usages \
# DRY_RUN=false ORGANIZATION_ID=<uuid>
#
# # 3. Apply for everyone (drop ORGANIZATION_ID):
# lago exec api bundle exec rails migrations:backfill_pay_in_advance_lifetime_usages \
# DRY_RUN=false
#
# Env:
# DRY_RUN "false" to apply the flags. Default: true (report only).
# ORGANIZATION_ID Restrict to a single organization. Default: all.

namespace :migrations do
desc "Flag lifetime usages with immediate pay-in-advance invoices for recalculation (DRY_RUN=true by default)"
task backfill_pay_in_advance_lifetime_usages: :environment do
Rails.logger.level = Logger::Severity::ERROR

batch_size = 1_000
org_id = ENV["ORGANIZATION_ID"].presence
dry_run = ENV.fetch("DRY_RUN", "true") != "false"

organizations = Organization.with_any_premium_integrations(%w[lifetime_usage progressive_billing])
organizations = organizations.where(id: org_id) if org_id

subscriptions = Subscription.active
.where(id: InvoiceSubscription.in_advance_charge.select(:subscription_id))

scope = LifetimeUsage
.where(organization_id: organizations.select(:id))
.where(subscription_id: subscriptions.select(:id))
.where(recalculate_invoiced_usage: false)

puts "##################################"
puts "Pay-in-advance lifetime usage refresh"
puts "Organization: #{org_id || "all"}, mode: #{dry_run ? "DRY-RUN (report only)" : "BACKFILL"}"
puts "=" * 50

pending = scope.count

if dry_run
puts "Lifetime usages that would be flagged for recalculation: #{pending}"
puts "\nRun again with DRY_RUN=false to apply the flags."
next
end

if pending.zero?
puts "Nothing to flag ✅"
next
end

puts "Flagging #{pending} lifetime usage(s) in batches of #{batch_size}..."

flagged = 0
scope.in_batches(of: batch_size) do |batch|
flagged += batch.update_all(recalculate_invoiced_usage: true) # rubocop:disable Rails/SkipsModelValidations
puts " -> #{flagged}/#{pending} flagged"
end

puts "\nDone ✅ Clock::RefreshLifetimeUsagesJob will recompute them on its next run."
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

require "rails_helper"

require "rake"

RSpec.describe "migrations:backfill_pay_in_advance_lifetime_usages" do # rubocop:disable RSpec/DescribeClass
let(:task) { Rake::Task["migrations:backfill_pay_in_advance_lifetime_usages"] }

let(:organization) { create(:organization, premium_integrations: ["lifetime_usage"]) }
let(:customer) { create(:customer, organization:) }
let(:subscription) { create(:subscription, customer:, organization:) }
let(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:, recalculate_invoiced_usage: false) }
let(:invoice) { create(:invoice, organization:, customer:) }

before do
Rake.application.rake_require("tasks/migrations/backfill_pay_in_advance_lifetime_usages")
Rake::Task.define_task(:environment)
task.reenable

lifetime_usage
create(:invoice_subscription, invoice:, subscription:, invoicing_reason: :in_advance_charge)

allow($stdout).to receive(:puts)
end

it "does not flag anything by default" do
task.invoke

expect(lifetime_usage.reload.recalculate_invoiced_usage).to be false
end

context "when DRY_RUN is false" do
around do |example|
ENV["DRY_RUN"] = "false"
example.run
ENV.delete("DRY_RUN")
end

it "flags the lifetime usage for recalculation" do
task.invoke

expect(lifetime_usage.reload.recalculate_invoiced_usage).to be true
end

context "when the organization has no lifetime usage integration" do
let(:organization) { create(:organization, premium_integrations: []) }

it "does not flag the lifetime usage" do
task.invoke

expect(lifetime_usage.reload.recalculate_invoiced_usage).to be false
end
end

context "when the subscription is terminated" do
let(:subscription) { create(:subscription, :terminated, customer:, organization:) }

it "does not flag the lifetime usage" do
task.invoke

expect(lifetime_usage.reload.recalculate_invoiced_usage).to be false
end
end

context "when the subscription has no in_advance_charge invoice" do
before do
InvoiceSubscription.update_all(invoicing_reason: :subscription_periodic) # rubocop:disable Rails/SkipsModelValidations
end

it "does not flag the lifetime usage" do
task.invoke

expect(lifetime_usage.reload.recalculate_invoiced_usage).to be false
end
end
end
end
111 changes: 111 additions & 0 deletions spec/scenarios/lifetime_usages/pay_in_advance_invoiceable_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

require "rails_helper"

describe "Lifetime usage with pay in advance invoiceable charges", :premium, :time_travel do
let(:organization) do
create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["lifetime_usage", "progressive_billing"])
end
let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) }
let(:customer) { create(:customer, organization:) }
let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") }
let(:charge) do
create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: true, properties: {amount: "10"})
end

before { charge }

def subscribe
create_subscription(
{
external_customer_id: customer.external_id,
external_id: customer.external_id,
plan_code: plan.code
}
)
customer.subscriptions.sole
end

# The immediate pay-in-advance invoice lands inside the still-open period, so its usage is on the
# invoiced side while the current usage still reports it. Whether it shows up doubled depends on
# when the invoiced counter was last refreshed, hence the two orderings below.
it "counts the open period usage once when the event lands before any lifetime usage refresh" do
subscription = subscribe

ingest_event(subscription, billable_metric, 1)

expect(Invoice.sole.fees.charge.sum(:amount_cents)).to eq(1000)

lifetime_usage = subscription.lifetime_usage.reload
expect(lifetime_usage.invoiced_usage_amount_cents).to eq(1000)
expect(lifetime_usage.current_usage_amount_cents).to be_zero
expect(lifetime_usage.total_amount_cents).to eq(1000)
end

it "counts the open period usage once when the event lands after a lifetime usage refresh" do
subscription = subscribe

pass_time 1.day

ingest_event(subscription, billable_metric, 1)

lifetime_usage = subscription.lifetime_usage.reload
expect(lifetime_usage.invoiced_usage_amount_cents).to eq(1000)
expect(lifetime_usage.current_usage_amount_cents).to be_zero
expect(lifetime_usage.total_amount_cents).to eq(1000)
end

it "keeps counting the usage once after the period rolls over" do
subscription = subscribe

ingest_event(subscription, billable_metric, 1)

pass_time 1.month

lifetime_usage = subscription.lifetime_usage.reload
expect(lifetime_usage.invoiced_usage_amount_cents).to eq(1000)
expect(lifetime_usage.current_usage_amount_cents).to be_zero
expect(lifetime_usage.total_amount_cents).to eq(1000)
end

context "with a usage threshold above the pay in advance amount" do
let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 1500) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A threshold of 1500 against 1000 of usage only fires when the total is doubled, so this covers the reported case but not the other ordering. The interesting one is a threshold below the invoiced amount: on main the check reports a 500 threshold as passed even though that usage had already been invoiced, and with this change it reports none. Adding that example would lock down the half of the fix that is easier to break by accident.


before { usage_threshold }

it "does not issue a progressive billing invoice when the event lands before any refresh" do
subscription = subscribe

ingest_event(subscription, billable_metric, 1)

expect(subscription.lifetime_usage.reload.total_amount_cents).to eq(1000)
expect(Invoice.progressive_billing.count).to be_zero
end

it "does not issue a progressive billing invoice when the event lands after a refresh" do
subscription = subscribe

pass_time 1.day

ingest_event(subscription, billable_metric, 1)

expect(subscription.lifetime_usage.reload.total_amount_cents).to eq(1000)
expect(Invoice.progressive_billing.count).to be_zero
end
end

context "with a usage threshold below the pay in advance amount" do
let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 500) }

before { usage_threshold }

it "does not issue a progressive billing invoice for usage that was already invoiced" do
subscription = subscribe

ingest_event(subscription, billable_metric, 1)

expect(subscription.lifetime_usage.reload.total_amount_cents).to eq(1000)
expect(Invoice.progressive_billing.count).to be_zero
end
end
end
Loading
Loading