fix(lifetime-usage): count advance usage only once - #6068
lago-claude-ai-agent[bot] wants to merge 2 commits into
Conversation
## Context "Total lifetime usage" is the sum of historical, invoiced and current usage, which assumes the invoiced and current counters are disjoint. An invoiceable pay-in-advance charge breaks that assumption: its usage is invoiced immediately, inside the still-open period, so it lands on the invoiced side while the current usage still reports it. A subscription with a single 10 euro event showed a lifetime total of 20 euro. Because the invoiced counter is cached behind a flag while the current one is recomputed on every activity-driven run, the doubling only sticks once something refreshes the invoiced side inside the open period, which is what subscription activation does. ## Description The open-period pay-in-advance invoiced amount is now subtracted from the current usage, keyed off the boundaries the fee stores in its properties, and the invoiced counter is refreshed in the same run whenever that amount is non-zero so both counters describe one snapshot. Once the period rolls over the subtraction stops and the usage stays counted once, on the invoiced side. This deliberately changes progressive billing: the threshold check reads these counters, so thresholds now fire at the customer's true cumulative usage instead of roughly double it for pay-in-advance invoiceable charges. Dormant subscriptions never recompute on their own, so a rake task flags them for recalculation. The invoiced usage query also becomes a single aggregate instead of one query per invoice, since it now runs on every recalculation. Signed-off-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com>
|
HOLD — the double count is real and the diagnosis looks right, but the fix picks one side of an unnamed fork that moves the meaning of a public API field and changes progressive-billing firing. That needs a human sign-off, not a re-run. Blocking:
Non-blocking:
Verified: CI green (10/10 spec shards, linters, migrations), commit verified and signed off, branch name clean, no secrets, five files, rubocop disables mirror existing rake/spec precedent. The unit specs are genuine regression tests — they assert 1000 where the old code yields 2000. Note: I could not read the originating ticket or its comments from this environment, so I cannot confirm whether the progressive-billing change and the public-field semantics were sanctioned there. If they were, that is most of this HOLD resolved. |
aquinofb
left a comment
There was a problem hiding this comment.
The fix looks right to me, and the specs earn their keep: if I revert only calculate_service.rb and keep the new tests, four of the five scenario examples fail, so they really do pin the double count instead of passing either way. I also ran the lifetime usage, progressive billing and advance charges suites together and nothing else moved.
Two things I would like to see changed before merge, and one that deserves its own ticket. They are in the inline comments.
| .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) |
There was a problem hiding this comment.
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?
| invoices.sum { |invoice| invoice.fees.charge.sum(:amount_cents) } | ||
| .select(:id) | ||
|
|
||
| organization.fees.charge.where(invoice_id: invoice_ids).sum(:amount_cents) |
There was a problem hiding this comment.
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.
| # 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. | ||
| if lifetime_usage.recalculate_invoiced_usage || pay_in_advance_invoiced_amount_cents.positive? |
There was a problem hiding this comment.
Small ordering detail: because || stops at the first true value, when the flag is already set the subtraction amount is computed after the invoiced sum rather than before it. In that order, a fee invoiced in between is taken off the current usage while still missing from the invoiced side, which is the mismatch this branch exists to prevent and the opposite of what the comment above it promises. Reading the amount into a local variable before the if puts both sides back on the same snapshot.
| end | ||
|
|
||
| context "with a usage threshold above the pay in advance amount" do | ||
| let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 1500) } |
There was a problem hiding this comment.
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.
|
Not for this PR, but I ran into it while reviewing and it is the same family of problem: the invoiced sum counts every charge fee on the matching invoices, not only the ones belonging to this subscription. With one invoice covering two subscriptions of the same customer, with fees of 100 and 900, the first subscription's lifetime usage reports 1000, both before and after this change. Since a customer's subscriptions are billed together on one invoice, any customer with more than one subscription has an inflated lifetime usage today. Better as its own ticket than widening this one. |
Signed-off-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com>
|
|
HOLD — the new open-period fee lookup adds an unbounded database scan to every activity-driven lifetime-usage recalculation.
|
`LifetimeUsages::CalculateService` selected the invoices covering a subscription and then summed **every** charge fee on them, without filtering the fees back to the subscription. Since a customer's subscriptions are billed together on one invoice, each one was credited with its siblings' usage: a shared invoice carrying a 100 fee for subscription A and a 900 fee for B reported 1000 for both. The fee sum is now filtered by the same subscription family already used to pick the invoices. This reaches further than the displayed total. `invoiced_usage_amount_cents` is the already-invoiced floor in `LifetimeUsages::UsageThresholds::CheckService`, so an inflated value could suppress progressive billing invoices that should fire *and* inflate the total that decides when a threshold is passed. Invoicing itself was unaffected. Existing values do not heal on their own — the amount is only recomputed once something sets `recalculate_invoiced_usage` — so `migrations:backfill_shared_invoice_lifetime_usages` flags the affected subscriptions in batches, defaulting to a dry run. It deliberately over-selects (every subscription on a shared invoice, without checking that the siblings actually carried charge fees), because re-flagging an already-correct subscription just recomputes the same value. Worth flagging for review: the existing specs asserted the old behaviour through a fixture gap. They built charge fees without passing `subscription:`, so the `charge_fee` factory attached each fee to its own freshly-created subscription, and the assertions only held because the sum ignored `subscription_id`. They now attach the fees to the subscription under test. Perf: the fee sum was an N+1 (one query per invoice, every invoice instantiated) and is now a single aggregate. Out of scope: terminated subscriptions will not recompute even once flagged, since `CalculateService` skips inactive subscriptions. Note this touches the same method as the open #6068 and will need a rebase on whichever lands second; the two fix independent causes. Signed-off-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com> Co-authored-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com>
"Total lifetime usage" counted invoiceable pay-in-advance usage twice: the immediate
charge_in_advanceinvoice lands ininvoiced_usage_amount_centswhile the same usage is still reported bycurrent_usage_amount_centsfor the open period, so one €10 event showed a €20 lifetime total.LifetimeUsages::CalculateServicenow subtracts the open-period pay-in-advance invoiced amount from the current usage, keyed off the fee's storedcharges_to_datetime— the fee carries the open-period boundaries, theinvoice_subscriptionof anin_advance_chargeinvoice does not. Since the invoiced counter is cached behindrecalculate_invoiced_usage, it is also refreshed whenever that amount is non-zero, so both counters come from one snapshot rather than one of them being stale. After rollover the subtraction stops and the usage stays counted once, on the invoiced side.This intentionally changes progressive billing:
UsageThresholds::CheckServicereads these counters, so thresholds now fire at the customer's true cumulative usage instead of roughly double it for pay-in-advance invoiceable charges. The added threshold spec locks that in.A rake task flags dormant subscriptions for recalculation, as they would otherwise keep serving the doubled total. Terminated subscriptions will not self-heal —
CalculateServiceskips inactive subscriptions.Perf:
calculate_invoiced_usage_amount_centswas an N+1 (a fee query per invoice) and is now a single aggregate, which matters because it can now run on every recalculation.Out of scope: non-invoiceable pay-in-advance charges have the opposite problem (their usage drops out of the total after rollover), tracked separately.