From f1bdf2221356780658cd6a0f06f97756a613eb22 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Fri, 11 Sep 2026 17:10:42 +0200 Subject: [PATCH 1/5] fix(past-usage): include free-tier units - Recover unbilled free advance fees in past usage periods - Cover billing, filtering, pagination, and timestamp behavior --- app/queries/past_usage_query.rb | 50 ++++++- spec/queries/past_usage_query_spec.rb | 155 ++++++++++++++++++++ spec/scenarios/past_usage/free_tier_spec.rb | 87 +++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 spec/scenarios/past_usage/free_tier_spec.rb diff --git a/app/queries/past_usage_query.rb b/app/queries/past_usage_query.rb index ead3b93bdf27..593167c33ca6 100644 --- a/app/queries/past_usage_query.rb +++ b/app/queries/past_usage_query.rb @@ -14,7 +14,7 @@ def call result.usage_periods = query_result.map do |invoice_subscription| UsagePeriods.new( invoice_subscription:, - fees: fees_query(invoice_subscription.invoice) + fees: fees_query(invoice_subscription) ) end @@ -45,11 +45,51 @@ def query base_query end - def fees_query(invoice) - query = invoice.fees.joins(:subscription).where(subscription: {external_id: filters.external_subscription_id}).charge.includes(:charge_filter, :presentation_breakdowns) - return query unless filters.billable_metric_code + def fees_query(invoice_subscription) + scope = Fee.joins(:subscription) + .where(subscription: {external_id: filters.external_subscription_id}) + .charge.includes(:charge_filter, :presentation_breakdowns) + if filters.billable_metric_code + scope = scope.joins(:charge).where(charges: {billable_metric_id: billable_metric.id}) + end + + # Keep these lookups separate so invoice and subscription indexes can bound each query. + fees = scope.where(invoice_id: invoice_subscription.invoice_id).to_a + if free_usage_period?(invoice_subscription) + fees.concat(scope.merge(free_fees(invoice_subscription)).to_a) + end + + fees + end + + def free_fees(invoice_subscription) + # Free advance fees retain metered units but can stay pending and never join + # the paid-fee invoice. Recover them using their original billing period. + # JSON fee boundaries have millisecond precision; invoice boundaries have microseconds. + Fee.where(organization:, subscription_id: invoice_subscription.subscription_id, invoice_id: nil, + pay_in_advance: true, amount_cents: 0, precise_amount_cents: 0) + .charge.positive_units.joins(:charge) + .where(charges: {pay_in_advance: true, invoiceable: false, regroup_paid_fees: :invoice}) + .where("(fees.properties ->> 'charges_from_datetime')::timestamptz = ?", invoice_subscription.charges_from_datetime.iso8601(3)) + .where("(fees.properties ->> 'charges_to_datetime')::timestamptz = ?", invoice_subscription.charges_to_datetime.iso8601(3)) + end + + def free_usage_period?(invoice_subscription) + # Prefer the regrouped invoice, falling back to the regular invoice for an + # entirely free period. Choose outside pagination so fees appear only once. + @free_usage_period_ids ||= {} + key = [invoice_subscription.subscription_id, invoice_subscription.charges_from_datetime, invoice_subscription.charges_to_datetime] + period_id = @free_usage_period_ids.fetch(key) do + @free_usage_period_ids[key] = InvoiceSubscription.where( + organization:, + subscription_id: invoice_subscription.subscription_id, + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime, + invoicing_reason: [:in_advance_charge_periodic, :subscription_periodic, :subscription_terminating] + ).order(Arel.sql("CASE WHEN invoicing_reason = 'in_advance_charge_periodic' THEN 0 ELSE 1 END"), :created_at, :id).pick(:id) + end - query.joins(:charge).where(charges: {billable_metric_id: billable_metric.id}) + invoice_subscription.id == period_id end def validate_filters diff --git a/spec/queries/past_usage_query_spec.rb b/spec/queries/past_usage_query_spec.rb index 26cfb158b32f..e4430c6636b0 100644 --- a/spec/queries/past_usage_query_spec.rb +++ b/spec/queries/past_usage_query_spec.rb @@ -224,4 +224,159 @@ end end end + + context "with unbilled free advance fees" do + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:graduated_charge, :regroup_paid_fees, plan:, billable_metric:) } + let(:free_fee_attributes) do + { + organization:, + subscription:, + charge:, + invoice: nil, + pay_in_advance: true, + amount_cents: 0, + precise_amount_cents: 0, + units: 40, + total_aggregated_units: 40, + properties: { + charges_from_datetime: invoice_subscription1.charges_from_datetime, + charges_to_datetime: invoice_subscription1.charges_to_datetime + } + } + end + let(:free_fee) { create(:charge_fee, **free_fee_attributes) } + let(:paid_fee) do + create(:charge_fee, organization:, subscription:, charge:, invoice: invoice_subscription1.invoice, + units: 10, total_aggregated_units: 10, amount_cents: 500) + end + + before do + invoice_subscription1.update!(invoicing_reason: :in_advance_charge_periodic) + invoice_subscription1.invoice.update!(invoice_type: :advance_charges) + free_fee + paid_fee + end + + it "includes free units from the same billing period" do + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + expect(result.usage_periods.last.fees).to be_empty + expect(free_fee.reload).to have_attributes(invoice_id: nil, payment_status: "pending") + end + + it "does not count free fees already attached to an invoice twice" do + free_fee.update!(invoice: invoice_subscription1.invoice) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "excludes payable, zero-unit, discarded and other-period fees" do + create(:charge_fee, **free_fee_attributes, amount_cents: 50, precise_amount_cents: 50) + create(:charge_fee, **free_fee_attributes, payment_status: :failed, amount_cents: 50, precise_amount_cents: 50) + create(:charge_fee, **free_fee_attributes, precise_amount_cents: 0.1) + create(:charge_fee, **free_fee_attributes, units: 0) + create(:charge_fee, **free_fee_attributes, deleted_at: Time.current) + create(:charge_fee, **free_fee_attributes, properties: { + charges_from_datetime: invoice_subscription2.charges_from_datetime, + charges_to_datetime: invoice_subscription2.charges_to_datetime + }) + create(:charge_fee, **free_fee_attributes, properties: {}) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "excludes other subscriptions and organizations even when external IDs match" do + other_subscription = create(:subscription, customer:, plan:, external_id: subscription.external_id, status: :terminated) + create(:charge_fee, **free_fee_attributes, subscription: other_subscription) + create(:charge_fee, **free_fee_attributes, subscription: subscription2) + other_customer = create(:customer, external_id: customer.external_id) + foreign_subscription = create(:subscription, customer: other_customer, external_id: subscription.external_id) + create(:charge_fee, **free_fee_attributes, organization: other_customer.organization, subscription: foreign_subscription) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "excludes charges without regrouping and invoiceable advance charges" do + standalone_charge = create(:graduated_charge, plan:, pay_in_advance: true, invoiceable: false) + invoiceable_charge = create(:graduated_charge, plan:, pay_in_advance: true, invoiceable: true) + create(:charge_fee, **free_fee_attributes, charge: standalone_charge) + create(:charge_fee, **free_fee_attributes, charge: invoiceable_charge) + create(:charge_fee, **free_fee_attributes, pay_in_advance: false) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "respects the billable metric filter for free fees" do + filters[:billable_metric_code] = charge.billable_metric.code + other_charge = create(:graduated_charge, :regroup_paid_fees, plan:) + create(:charge_fee, **free_fee_attributes, charge: other_charge) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "includes free units in charge filters, grouped usage and presentation breakdowns" do + charge_filter = create(:charge_filter, charge:) + [paid_fee, free_fee].each do |fee| + fee.update!(charge_filter:, grouped_by: {region: "eu"}, events_count: 1) + create(:presentation_breakdown, fee:, organization:, presentation_by: {model: "basic"}, units: fee.units) + end + + usage = V1::Customers::ChargeUsageSerializer.new(result.usage_periods.first.fees, root_name: "past_usage").serialize.sole + expected_usage = {units: "50.0", total_aggregated_units: "50.0", events_count: 2, amount_cents: 500} + + expect(usage).to include(expected_usage) + expect(usage[:filters].sole).to include(expected_usage) + expect(usage[:grouped_usage].sole).to include(expected_usage) + expect(usage[:grouped_usage].sole[:filters].sole).to include(expected_usage) + expect(usage[:grouped_usage].sole[:filters].sole[:presentation_breakdowns].pluck(:units)).to match_array(["10.0", "40.0"]) + end + + it "matches equivalent timestamps with a timezone offset" do + free_fee.update!(properties: { + charges_from_datetime: invoice_subscription1.charges_from_datetime.in_time_zone("Europe/Paris").iso8601, + charges_to_datetime: invoice_subscription1.charges_to_datetime.in_time_zone("Europe/Paris").iso8601 + }) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + it "retains fees for discarded charges" do + charge.discard! + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + + context "with a regular invoice for the same period" do + let!(:regular_period) do + create(:invoice_subscription, organization:, subscription:, + invoicing_reason: :subscription_periodic, + charges_from_datetime: invoice_subscription1.charges_from_datetime, + charges_to_datetime: invoice_subscription1.charges_to_datetime) + end + + it "includes free fees only in the regrouped invoice's usage" do + expect(result.usage_periods.find { |period| period.invoice_subscription == regular_period }.fees).to be_empty + expect(result.usage_periods.flat_map { |period| period.fees.to_a }).to match_array([paid_fee, free_fee]) + end + + context "when pagination excludes the regrouped invoice" do + let(:pagination) { {page: 1, limit: 1} } + + before { regular_period.update!(created_at: 1.day.from_now) } + + it "does not move free usage into the regular invoice" do + expect(result.usage_periods.sole.invoice_subscription).to eq(regular_period) + expect(result.usage_periods.sole.fees).to be_empty + end + end + end + + context "when the period has only a regular invoice" do + before { invoice_subscription1.update!(invoicing_reason: :subscription_periodic) } + + it "includes the free fees in that period" do + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + end + end end diff --git a/spec/scenarios/past_usage/free_tier_spec.rb b/spec/scenarios/past_usage/free_tier_spec.rb new file mode 100644 index 000000000000..81ba7f9339d5 --- /dev/null +++ b/spec/scenarios/past_usage/free_tier_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Past usage for regrouped advance charges", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, interval: "monthly") } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:external_subscription_id) { SecureRandom.uuid } + let(:paid_units) { 10 } + + before do + create( + :graduated_charge, + plan:, + billable_metric:, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 40, per_unit_amount: "0", flat_amount: "0"}, + {from_value: 41, to_value: nil, per_unit_amount: "0.50", flat_amount: "0"} + ] + } + ) + end + + shared_examples "complete past usage" do + it "preserves the live unit count after billing without changing invoice amounts" do + travel_to(Time.zone.local(2024, 6, 1)) do + create_subscription({external_customer_id: customer.external_id, external_id: external_subscription_id, plan_code: plan.code}) + end + + subscription = customer.subscriptions.sole + + [40, paid_units].reject(&:zero?).each_with_index do |units, index| + travel_to(Time.zone.local(2024, 6, 15 + index, 12)) do + create_event({ + code: billable_metric.code, + external_subscription_id:, + properties: {billable_metric.field_name => units} + }) + end + end + + travel_to(Time.zone.local(2024, 6, 20, 12)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].sole[:units]).to eq((40 + paid_units).to_d.to_s) + + subscription.fees.charge.where("amount_cents > 0").find_each do |fee| + update_fee(fee.id, {payment_status: "succeeded"}) + end + expect(subscription.fees.charge.where(amount_cents: 0).sole).to have_attributes( + units: 40, + invoice_id: nil, + payment_status: "pending" + ) + end + + travel_to(Time.zone.local(2024, 7, 1, 1)) { perform_billing } + + invoice_amounts = customer.invoices.order(:id).pluck(:id, :fees_amount_cents, :total_amount_cents) + + get_with_token(organization, "/api/v1/customers/#{customer.external_id}/past_usage", {external_subscription_id:}) + + expect(response).to have_http_status(:success) + charges_usage = json[:usage_periods].flat_map { |period| period[:charges_usage] } + expect(charges_usage.sole).to include( + units: (40 + paid_units).to_d.to_s, + total_aggregated_units: (40 + paid_units).to_d.to_s, + amount_cents: paid_units * 50 + ) + expect(customer.invoices.order(:id).pluck(:id, :fees_amount_cents, :total_amount_cents)).to eq(invoice_amounts) + expect(subscription.fees.charge.where(amount_cents: 0).sole.invoice_id).to be_nil + end + end + + include_examples "complete past usage" + + context "when all consumption is free" do + let(:paid_units) { 0 } + + include_examples "complete past usage" + end +end From 1490e09280f36072775b5e316a19242806c0f48d Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Fri, 11 Sep 2026 17:39:49 +0200 Subject: [PATCH 2/5] fix(past-usage): batch free-fee lookups --- app/queries/past_usage_query.rb | 87 ++++++++++++++++++--------- spec/queries/past_usage_query_spec.rb | 56 +++++++++++++++++ 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/app/queries/past_usage_query.rb b/app/queries/past_usage_query.rb index 593167c33ca6..699e7486e53a 100644 --- a/app/queries/past_usage_query.rb +++ b/app/queries/past_usage_query.rb @@ -11,10 +11,11 @@ def call return result if result.error.present? query_result = apply_consistent_ordering(query) + free_usage_fees = free_fees_by_period(query_result.to_a) result.usage_periods = query_result.map do |invoice_subscription| UsagePeriods.new( invoice_subscription:, - fees: fees_query(invoice_subscription) + fees: fees_query(invoice_subscription) + free_usage_fees.fetch(invoice_subscription.id, []) ) end @@ -53,43 +54,71 @@ def fees_query(invoice_subscription) scope = scope.joins(:charge).where(charges: {billable_metric_id: billable_metric.id}) end - # Keep these lookups separate so invoice and subscription indexes can bound each query. - fees = scope.where(invoice_id: invoice_subscription.invoice_id).to_a - if free_usage_period?(invoice_subscription) - fees.concat(scope.merge(free_fees(invoice_subscription)).to_a) - end + scope.where(invoice_id: invoice_subscription.invoice_id).to_a + end - fees + def free_fees_by_period(invoice_subscriptions) + return {} if invoice_subscriptions.empty? + + owner_ids = free_usage_period_ids(invoice_subscriptions) + periods_by_id = invoice_subscriptions.index_by(&:id) + periods = owner_ids.values.filter_map { |id| periods_by_id[id] } + if periods.empty? + {} + else + free_fees(periods).group_by do |fee| + owner_ids[usage_period_key(fee.subscription_id, fee.properties["charges_from_datetime"], fee.properties["charges_to_datetime"])] + end + end end - def free_fees(invoice_subscription) - # Free advance fees retain metered units but can stay pending and never join - # the paid-fee invoice. Recover them using their original billing period. - # JSON fee boundaries have millisecond precision; invoice boundaries have microseconds. - Fee.where(organization:, subscription_id: invoice_subscription.subscription_id, invoice_id: nil, + def free_fees(periods) + # Match all requested periods in one scan of standalone fees. JSON boundaries + # have millisecond precision; invoice boundaries have microseconds. + conditions = periods.map do |period| + Fee.where(subscription_id: period.subscription_id) + .where("(fees.properties ->> 'charges_from_datetime')::timestamptz = ?", period.charges_from_datetime.iso8601(3)) + .where("(fees.properties ->> 'charges_to_datetime')::timestamptz = ?", period.charges_to_datetime.iso8601(3)) + end.reduce { |scope, condition| scope.or(condition) } + + scope = Fee.where(organization:, subscription_id: periods.map(&:subscription_id).uniq, invoice_id: nil, pay_in_advance: true, amount_cents: 0, precise_amount_cents: 0) .charge.positive_units.joins(:charge) .where(charges: {pay_in_advance: true, invoiceable: false, regroup_paid_fees: :invoice}) - .where("(fees.properties ->> 'charges_from_datetime')::timestamptz = ?", invoice_subscription.charges_from_datetime.iso8601(3)) - .where("(fees.properties ->> 'charges_to_datetime')::timestamptz = ?", invoice_subscription.charges_to_datetime.iso8601(3)) - end + .merge(conditions) + .includes(:charge_filter, :presentation_breakdowns) - def free_usage_period?(invoice_subscription) - # Prefer the regrouped invoice, falling back to the regular invoice for an - # entirely free period. Choose outside pagination so fees appear only once. - @free_usage_period_ids ||= {} - key = [invoice_subscription.subscription_id, invoice_subscription.charges_from_datetime, invoice_subscription.charges_to_datetime] - period_id = @free_usage_period_ids.fetch(key) do - @free_usage_period_ids[key] = InvoiceSubscription.where( - organization:, - subscription_id: invoice_subscription.subscription_id, - charges_from_datetime: invoice_subscription.charges_from_datetime, - charges_to_datetime: invoice_subscription.charges_to_datetime, - invoicing_reason: [:in_advance_charge_periodic, :subscription_periodic, :subscription_terminating] - ).order(Arel.sql("CASE WHEN invoicing_reason = 'in_advance_charge_periodic' THEN 0 ELSE 1 END"), :created_at, :id).pick(:id) + if filters.billable_metric_code + scope = scope.where(charges: {billable_metric_id: billable_metric.id}) end - invoice_subscription.id == period_id + scope + end + + def free_usage_period_ids(periods) + # Resolve owners for the whole page, including competing invoices outside it. + # Prefer regrouped invoices, then regular invoices for entirely free periods. + conditions = periods.map do |period| + InvoiceSubscription.where( + subscription_id: period.subscription_id, + charges_from_datetime: period.charges_from_datetime, + charges_to_datetime: period.charges_to_datetime + ) + end.reduce { |scope, condition| scope.or(condition) } + + InvoiceSubscription.where(organization:, + invoicing_reason: [:in_advance_charge_periodic, :subscription_periodic, :subscription_terminating]) + .merge(conditions) + .select(:id, :subscription_id, :charges_from_datetime, :charges_to_datetime) + .order(Arel.sql("CASE WHEN invoicing_reason = 'in_advance_charge_periodic' THEN 0 ELSE 1 END"), :created_at, :id) + .each_with_object({}) do |period, owners| + key = usage_period_key(period.subscription_id, period.charges_from_datetime, period.charges_to_datetime) + owners[key] ||= period.id + end + end + + def usage_period_key(subscription_id, from_datetime, to_datetime) + [subscription_id, from_datetime.to_time.getutc.iso8601(3), to_datetime.to_time.getutc.iso8601(3)] end def validate_filters diff --git a/spec/queries/past_usage_query_spec.rb b/spec/queries/past_usage_query_spec.rb index e4430c6636b0..cf3ff0a70ea3 100644 --- a/spec/queries/past_usage_query_spec.rb +++ b/spec/queries/past_usage_query_spec.rb @@ -346,6 +346,62 @@ expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) end + context "when loading multiple billing periods" do + let(:pagination) { {page: 1, limit: 100} } + let(:period_count) { 6 } + let(:include_free_fees) { true } + + before do + (2...period_count).each do |offset| + create(:invoice_subscription, organization:, subscription:, + invoicing_reason: :in_advance_charge_periodic, + charges_from_datetime: invoice_subscription1.charges_from_datetime - offset.months, + charges_to_datetime: invoice_subscription1.charges_to_datetime - offset.months) + end + invoice_subscription2.update!(invoicing_reason: :in_advance_charge_periodic) + subscription.invoice_subscriptions.where.not(id: invoice_subscription1.id).find_each do |period| + create(:charge_fee, **free_fee_attributes, properties: { + charges_from_datetime: period.charges_from_datetime, + charges_to_datetime: period.charges_to_datetime + }) + end + subscription.fees.where(invoice_id: nil).discard_all! unless include_free_fees + end + + shared_examples "batched free usage" do + it "batches owner resolution and free-fee retrieval across the page" do + queries = [] + subscriber = ->(_name, _start, _finish, _id, payload) { queries << payload[:sql] } + + ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do + expect(result.usage_periods.size).to eq(period_count) + expect(result.usage_periods.flat_map(&:fees).size).to eq(include_free_fees ? period_count + 1 : 1) + expected_units = include_free_fees ? [50] + [40] * (period_count - 1) : [10] + [0] * (period_count - 1) + expect(result.usage_periods.map { |period| period.fees.sum(&:units) }).to eq(expected_units) + end + + period_queries = queries.select { |sql| sql.include?('FROM "invoice_subscriptions"') && !sql.include?("COUNT(") } + free_fee_queries = queries.select { |sql| sql.include?('FROM "fees"') && sql.include?('"fees"."invoice_id" IS NULL') } + expect(period_queries.size).to eq(2) + expect(free_fee_queries.size).to eq(1) + end + end + + include_examples "batched free usage" + + context "with a smaller page" do + let(:period_count) { 2 } + + include_examples "batched free usage" + end + + context "when no free fees qualify" do + let(:include_free_fees) { false } + + include_examples "batched free usage" + end + end + context "with a regular invoice for the same period" do let!(:regular_period) do create(:invoice_subscription, organization:, subscription:, From 71a8ba304031a4a7efc974b648ab98085dc697a5 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 14 Sep 2026 08:58:30 +0200 Subject: [PATCH 3/5] fix(past-usage): exclude superseded periods --- app/queries/past_usage_query.rb | 2 +- spec/queries/past_usage_query_spec.rb | 32 ++++++++++++++++++++ spec/scenarios/past_usage/free_tier_spec.rb | 33 ++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/queries/past_usage_query.rb b/app/queries/past_usage_query.rb index 699e7486e53a..6125a0594aaa 100644 --- a/app/queries/past_usage_query.rb +++ b/app/queries/past_usage_query.rb @@ -106,7 +106,7 @@ def free_usage_period_ids(periods) ) end.reduce { |scope, condition| scope.or(condition) } - InvoiceSubscription.where(organization:, + InvoiceSubscription.where(organization:, regenerated_invoice_id: nil, invoicing_reason: [:in_advance_charge_periodic, :subscription_periodic, :subscription_terminating]) .merge(conditions) .select(:id, :subscription_id, :charges_from_datetime, :charges_to_datetime) diff --git a/spec/queries/past_usage_query_spec.rb b/spec/queries/past_usage_query_spec.rb index cf3ff0a70ea3..1a4a4ea26b7e 100644 --- a/spec/queries/past_usage_query_spec.rb +++ b/spec/queries/past_usage_query_spec.rb @@ -427,6 +427,38 @@ end end + context "when the invoice has been regenerated" do + let(:regenerated_invoice) { create(:invoice, organization:, customer:, invoice_type: :advance_charges) } + let(:regenerated_period) do + create(:invoice_subscription, organization:, subscription:, invoice: regenerated_invoice, + invoicing_reason: :in_advance_charge_periodic, + charges_from_datetime: invoice_subscription1.charges_from_datetime, + charges_to_datetime: invoice_subscription1.charges_to_datetime, + created_at: invoice_subscription1.created_at + 1.second) + end + + before do + invoice_subscription1.update!(regenerated_invoice_id: regenerated_invoice.id) + regenerated_period + end + + it "assigns the free fees to the replacement and retains the historical period" do + periods = result.usage_periods.index_by { |period| period.invoice_subscription.id } + + expect(periods.fetch(regenerated_period.id).fees).to eq([free_fee]) + expect(periods.fetch(invoice_subscription1.id).fees).to eq([paid_fee]) + end + + context "when only the superseded period is on the page" do + let(:pagination) { {page: 2, limit: 1} } + + it "does not assign free fees to the superseded period" do + expect(result.usage_periods.sole.invoice_subscription).to eq(invoice_subscription1) + expect(result.usage_periods.sole.fees).to eq([paid_fee]) + end + end + end + context "when the period has only a regular invoice" do before { invoice_subscription1.update!(invoicing_reason: :subscription_periodic) } diff --git a/spec/scenarios/past_usage/free_tier_spec.rb b/spec/scenarios/past_usage/free_tier_spec.rb index 81ba7f9339d5..039b20b38919 100644 --- a/spec/scenarios/past_usage/free_tier_spec.rb +++ b/spec/scenarios/past_usage/free_tier_spec.rb @@ -27,7 +27,7 @@ ) end - shared_examples "complete past usage" do + shared_examples "complete past usage" do |regenerations: 0| it "preserves the live unit count after billing without changing invoice amounts" do travel_to(Time.zone.local(2024, 6, 1)) do create_subscription({external_customer_id: customer.external_id, external_id: external_subscription_id, plan_code: plan.code}) @@ -74,11 +74,42 @@ ) expect(customer.invoices.order(:id).pluck(:id, :fees_amount_cents, :total_amount_cents)).to eq(invoice_amounts) expect(subscription.fees.charge.where(amount_cents: 0).sole.invoice_id).to be_nil + + regenerations.times do |index| + travel_to(Time.zone.local(2024, 7, 2 + index, 12)) do + invoice = customer.invoices.advance_charges.where(status: :finalized).sole + void_invoice(invoice) + regenerated_invoice = Invoices::RegenerateFromVoidedService.call!( + voided_invoice: invoice, + fees_params: [{id: invoice.fees.charge.sole.id, subscription_id: subscription.id, units: 10, unit_amount_cents: "0.50"}] + ).invoice + + get_with_token(organization, "/api/v1/customers/#{customer.external_id}/past_usage", {external_subscription_id:}) + + expect(response).to have_http_status(:success) + periods = json[:usage_periods].index_by { |period| period[:lago_invoice_id] } + expect(periods.fetch(regenerated_invoice.id)[:charges_usage].sole).to include(units: "50.0", amount_cents: 500) + expect(periods.fetch(invoice.id)[:charges_usage].sole).to include(units: "10.0", amount_cents: 500) + expect(regenerated_invoice.fees_amount_cents).to eq(500) + expect(subscription.fees.charge.where(invoice_id: nil).sole.units).to eq(40) + + get_with_token(organization, "/api/v1/customers/#{customer.external_id}/past_usage", + {external_subscription_id:, page: 1, per_page: 1}) + + expect(response).to have_http_status(:success) + expect(json[:usage_periods].sole[:lago_invoice_id]).to eq(regenerated_invoice.id) + expect(json[:usage_periods].sole[:charges_usage].sole).to include(units: "50.0", amount_cents: 500) + end + end end end include_examples "complete past usage" + context "when the invoice is regenerated repeatedly", :with_pdf_generation_stub do + include_examples "complete past usage", regenerations: 2 + end + context "when all consumption is free" do let(:paid_units) { 0 } From 1e05b6ba589de6b722f36cfd4e1b427d23852e9b Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 14 Sep 2026 09:56:00 +0200 Subject: [PATCH 4/5] fix(past-usage): match free fees on the period start --- app/queries/past_usage_query.rb | 18 ++++---- spec/queries/past_usage_query_spec.rb | 6 +++ spec/scenarios/past_usage/free_tier_spec.rb | 46 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/app/queries/past_usage_query.rb b/app/queries/past_usage_query.rb index 6125a0594aaa..a792bea32419 100644 --- a/app/queries/past_usage_query.rb +++ b/app/queries/past_usage_query.rb @@ -67,18 +67,19 @@ def free_fees_by_period(invoice_subscriptions) {} else free_fees(periods).group_by do |fee| - owner_ids[usage_period_key(fee.subscription_id, fee.properties["charges_from_datetime"], fee.properties["charges_to_datetime"])] + owner_ids[usage_period_key(fee.subscription_id, fee.properties["charges_from_datetime"])] end end end def free_fees(periods) - # Match all requested periods in one scan of standalone fees. JSON boundaries + # Match all requested periods in one scan of standalone fees, on the period start + # only: a fee keeps the period end known when it was created, which outlives the + # invoice boundaries when the subscription is terminated mid-period. JSON boundaries # have millisecond precision; invoice boundaries have microseconds. conditions = periods.map do |period| Fee.where(subscription_id: period.subscription_id) .where("(fees.properties ->> 'charges_from_datetime')::timestamptz = ?", period.charges_from_datetime.iso8601(3)) - .where("(fees.properties ->> 'charges_to_datetime')::timestamptz = ?", period.charges_to_datetime.iso8601(3)) end.reduce { |scope, condition| scope.or(condition) } scope = Fee.where(organization:, subscription_id: periods.map(&:subscription_id).uniq, invoice_id: nil, @@ -101,24 +102,23 @@ def free_usage_period_ids(periods) conditions = periods.map do |period| InvoiceSubscription.where( subscription_id: period.subscription_id, - charges_from_datetime: period.charges_from_datetime, - charges_to_datetime: period.charges_to_datetime + charges_from_datetime: period.charges_from_datetime ) end.reduce { |scope, condition| scope.or(condition) } InvoiceSubscription.where(organization:, regenerated_invoice_id: nil, invoicing_reason: [:in_advance_charge_periodic, :subscription_periodic, :subscription_terminating]) .merge(conditions) - .select(:id, :subscription_id, :charges_from_datetime, :charges_to_datetime) + .select(:id, :subscription_id, :charges_from_datetime) .order(Arel.sql("CASE WHEN invoicing_reason = 'in_advance_charge_periodic' THEN 0 ELSE 1 END"), :created_at, :id) .each_with_object({}) do |period, owners| - key = usage_period_key(period.subscription_id, period.charges_from_datetime, period.charges_to_datetime) + key = usage_period_key(period.subscription_id, period.charges_from_datetime) owners[key] ||= period.id end end - def usage_period_key(subscription_id, from_datetime, to_datetime) - [subscription_id, from_datetime.to_time.getutc.iso8601(3), to_datetime.to_time.getutc.iso8601(3)] + def usage_period_key(subscription_id, from_datetime) + [subscription_id, from_datetime.to_time.getutc.iso8601(3)] end def validate_filters diff --git a/spec/queries/past_usage_query_spec.rb b/spec/queries/past_usage_query_spec.rb index 1a4a4ea26b7e..258d56218456 100644 --- a/spec/queries/past_usage_query_spec.rb +++ b/spec/queries/past_usage_query_spec.rb @@ -331,6 +331,12 @@ expect(usage[:grouped_usage].sole[:filters].sole[:presentation_breakdowns].pluck(:units)).to match_array(["10.0", "40.0"]) end + it "includes free fees when the period was truncated by a termination" do + invoice_subscription1.update!(charges_to_datetime: invoice_subscription1.charges_to_datetime - 10.days) + + expect(result.usage_periods.first.fees).to match_array([paid_fee, free_fee]) + end + it "matches equivalent timestamps with a timezone offset" do free_fee.update!(properties: { charges_from_datetime: invoice_subscription1.charges_from_datetime.in_time_zone("Europe/Paris").iso8601, diff --git a/spec/scenarios/past_usage/free_tier_spec.rb b/spec/scenarios/past_usage/free_tier_spec.rb index 039b20b38919..4558d88d760b 100644 --- a/spec/scenarios/past_usage/free_tier_spec.rb +++ b/spec/scenarios/past_usage/free_tier_spec.rb @@ -115,4 +115,50 @@ include_examples "complete past usage" end + + context "when the subscription is terminated mid-period" do + it "returns the free units on the truncated period" do + travel_to(Time.zone.local(2024, 6, 1)) do + create_subscription({external_customer_id: customer.external_id, external_id: external_subscription_id, plan_code: plan.code}) + end + + subscription = customer.subscriptions.sole + + [40, 10].each_with_index do |units, index| + travel_to(Time.zone.local(2024, 6, 15 + index, 12)) do + create_event({ + code: billable_metric.code, + external_subscription_id:, + properties: {billable_metric.field_name => units} + }) + end + end + + travel_to(Time.zone.local(2024, 6, 20, 12)) do + subscription.fees.charge.where("amount_cents > 0").find_each do |fee| + update_fee(fee.id, {payment_status: "succeeded"}) + end + end + + travel_to(Time.zone.local(2024, 6, 25, 12)) { terminate_subscription(subscription) } + + travel_to(Time.zone.local(2024, 6, 26, 12)) do + # The free fee keeps the period end it was created with, while every invoice + # of the period is truncated at the termination date. + expect(subscription.fees.charge.where(invoice_id: nil).sole.properties["charges_to_datetime"]) + .to eq("2024-06-30T23:59:59.999Z") + expect(subscription.invoice_subscriptions.pluck(:charges_to_datetime)).to all(eq(Time.zone.local(2024, 6, 25, 12))) + + get_with_token(organization, "/api/v1/customers/#{customer.external_id}/past_usage", {external_subscription_id:}) + + expect(response).to have_http_status(:success) + charges_usage = json[:usage_periods].flat_map { |period| period[:charges_usage] } + expect(charges_usage.sole).to include( + units: "50.0", + total_aggregated_units: "50.0", + amount_cents: 500 + ) + end + end + end end From 62c5d1c2616b91659477ba1de604952d36b5cda3 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 14 Sep 2026 09:56:21 +0200 Subject: [PATCH 5/5] test(past-usage): cover delayed fee regrouping --- spec/scenarios/past_usage/free_tier_spec.rb | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/spec/scenarios/past_usage/free_tier_spec.rb b/spec/scenarios/past_usage/free_tier_spec.rb index 4558d88d760b..c01949b8e08c 100644 --- a/spec/scenarios/past_usage/free_tier_spec.rb +++ b/spec/scenarios/past_usage/free_tier_spec.rb @@ -161,4 +161,55 @@ end end end + + context "when the paid fee is regrouped after its usage period" do + it "reports each fee in the period it was consumed in" do + travel_to(Time.zone.local(2024, 6, 1)) do + create_subscription({external_customer_id: customer.external_id, external_id: external_subscription_id, plan_code: plan.code}) + end + + subscription = customer.subscriptions.sole + + [40, 10].each_with_index do |units, index| + travel_to(Time.zone.local(2024, 6, 15 + index, 12)) do + create_event({ + code: billable_metric.code, + external_subscription_id:, + properties: {billable_metric.field_name => units} + }) + end + end + + travel_to(Time.zone.local(2024, 7, 1, 1)) { perform_billing } + travel_to(Time.zone.local(2024, 8, 1, 1)) { perform_billing } + + travel_to(Time.zone.local(2024, 8, 10, 12)) do + subscription.fees.charge.where("amount_cents > 0").find_each do |fee| + update_fee(fee.id, {payment_status: "succeeded"}) + end + end + + travel_to(Time.zone.local(2024, 9, 1, 1)) { perform_billing } + + travel_to(Time.zone.local(2024, 9, 2, 12)) do + advance_invoice = customer.invoices.advance_charges.sole + june_period = subscription.invoice_subscriptions.find_by(charges_from_datetime: Time.zone.local(2024, 6, 1)) + + # The regrouping invoice is stamped with the period it is issued in, not with + # the period the fee was consumed in. + expect(advance_invoice.invoice_subscriptions.sole.charges_from_datetime).to eq(Time.zone.local(2024, 8, 1)) + + get_with_token(organization, "/api/v1/customers/#{customer.external_id}/past_usage", {external_subscription_id:}) + + expect(response).to have_http_status(:success) + periods = json[:usage_periods].index_by { |period| period[:lago_invoice_id] } + expect(periods.fetch(june_period.invoice_id)[:charges_usage].sole).to include( + units: "40.0", + total_aggregated_units: "40.0", + amount_cents: 0 + ) + expect(periods.fetch(advance_invoice.id)[:charges_usage].sole).to include(units: "10.0", amount_cents: 500) + end + end + end end