-
Notifications
You must be signed in to change notification settings - Fork 191
fix(lifetime-usage): count advance usage only once #6068
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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) | ||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 A fee whose period ends in the future can only have been created during the current period, so adding |
||
| .sum(:amount_cents) | ||
| end | ||
|
|
||
| def current_usage | ||
|
|
||
| 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 |
| 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) } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment.
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.