Skip to content

fix(subscriptions): update existing charge on overridden plan in place (#5549)#5557

Open
nlenepveu wants to merge 1 commit into
getlago:mainfrom
blackfuel-ai:fix/update-or-override-charge-detects-existing-charge-on-overridden-plan
Open

fix(subscriptions): update existing charge on overridden plan in place (#5549)#5557
nlenepveu wants to merge 1 commit into
getlago:mainfrom
blackfuel-ai:fix/update-or-override-charge-detects-existing-charge-on-overridden-plan

Conversation

@nlenepveu
Copy link
Copy Markdown

Context

Closes #5549.

When a subscription has been updated through the legacy PATCH /subscriptions/{external_id} plan_overrides path and is later updated through the new per-charge PUT /subscriptions/{external_id}/charges/{code} endpoint, the overridden plan ends up holding two charges with the same billable_metric_id and code. Both are enumerated by the canonical fee-creation path (app/services/invoices/calculate_fees_service.rb#create_charges_fees) with no de-duplication, so each event produces two Fee rows. The full source trace and a reproduction sequence on api.eu.getlago.com are in #5549.

Implements option 2 from the issue, per @rsempe's suggestion:

Subscriptions::UpdateOrOverrideChargeService will detect when the resolved charge is already on the overridden plan and update it in place rather than creating a duplicate.

Change

In Subscriptions::UpdateOrOverrideChargeService#find_or_update_charge_override(target_plan), short-circuit when the resolved charge already lives on the overridden plan — update it in place rather than walking up to find_parent_charge and creating a new override on top.

def find_or_update_charge_override(target_plan)
  return update_charge_override(charge) if charge.plan_id == target_plan.id

  parent_charge = find_parent_charge
  existing_override = target_plan.charges.find_by(parent_id: parent_charge.id)

  if existing_override
    update_charge_override(existing_override)
  else
    create_charge_override(parent_charge, target_plan)
  end
end

Behaviour across the three scenarios

Subscription state Resolved charge.plan_id vs. target_plan.id Behaviour
Fresh — on the base plan, no overridden plan yet base_plan.id != overridden_plan.id Short-circuit doesn't fire. ensure_plan_override creates the overridden plan and populates it via Charges::OverrideService. Existing branch finds the freshly-created override via parent_id match and updates it. Unchanged.
Already overridden via per-charge PUT — parent_id points at base-plan charge overridden_plan.id == overridden_plan.id Short-circuit fires → updates the override in place. Same effective behaviour as before, different code path.
Already overridden via legacy plan_overrides PATCH — parent_id: nil, charge sits directly on the overridden plan via Plans::UpdateService#process_charges overridden_plan.id == overridden_plan.id Short-circuit fires → updates the legacy charge in place. No second override created. Bug fixed.

Tests

Existing spec contexts in spec/services/subscriptions/update_or_override_charge_service_spec.rb continue to pass; new context "when the charge passed lives directly on the overridden plan with parent_id: nil" covers scenario 3 explicitly:

  • Asserts Charge.count does not change (no second override).
  • Asserts the existing charge is updated in place (parent_id still nil, same plan_id, override params applied).
  • Asserts the overridden plan has exactly one charge for the billable_metric after the call.

Out of scope

  • Cleanup of already-affected production subscriptions. That stays a Lago-side concern (DB / support ticket); this fix only prevents new duplicates from forming.
  • The full DELETE /subscriptions/{external_id}/charges/{code} endpoint (option 1 from the issue). Left for a follow-up.

cc @rsempe — thanks for the quick triage on #5549.

When a subscription is updated through `PUT /subscriptions/{external_id}
/charges/{code}` and its overridden plan already carries a charge for
the same billable metric — e.g. one created earlier by
`Plans::UpdateService#process_charges` via the legacy `plan_overrides`
PATCH path, which sits directly on the overridden plan with
`parent_id: nil` — `UpdateOrOverrideChargeService` would walk up to
`find_parent_charge`, fail to find an existing override with that
parent, and create a second override on top. The overridden plan
ended up holding two charges with the same `billable_metric_id` and
`code`, both contributing fees over the same events.

Short-circuit when the resolved charge already lives on the overridden
plan: update it in place instead of creating a new override. Existing
behaviour is preserved in the other branches:

- Fresh subscription on the base plan: charge.plan_id != target_plan.id,
  the short-circuit doesn't fire, `ensure_plan_override` populates the
  overridden plan via `Charges::OverrideService`, and the existing
  `parent_id` lookup finds and updates the freshly-created override.
- Subscription whose overridden plan already carries a PUT-managed
  override (`parent_id` pointing at a base-plan charge): the short-
  circuit now updates that charge directly — same effective behaviour
  as before, different code path.

Closes getlago#5549.
@cla-check-bot
Copy link
Copy Markdown

cla-check-bot Bot commented May 21, 2026

Welcome, @nlenepveu!

Thanks for your first contribution!

Before we proceed with the review, please sign the Fiduciary License Agreement:

Sign the FLA

Once signed, this PR will be automatically updated.

@cla-check-bot
Copy link
Copy Markdown

cla-check-bot Bot commented May 21, 2026

Thanks, @nlenepveu! 🎉

Your CLA has been signed and is now on file. We'll proceed with the review shortly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switching from plan_overrides PATCH to per-charge PUT produces duplicate billable charges, with no API path to clean them up

1 participant