Skip to content

feat(multi-billing-entity): support billing entity on subscription upgrade#5503

Open
aquinofb wants to merge 3 commits into
mainfrom
ing-119
Open

feat(multi-billing-entity): support billing entity on subscription upgrade#5503
aquinofb wants to merge 3 commits into
mainfrom
ing-119

Conversation

@aquinofb
Copy link
Copy Markdown
Contributor

@aquinofb aquinofb commented May 13, 2026

Context

Multi-billing-entity support has been rolling out across the API a slice at a time. Subscription create landed in #5467 and wallets in #5471. The upgrade path was the next obvious gap: if you'd explicitly bound a subscription to a non-default entity (say eu instead of the customer's default us), upgrading the plan silently dropped that binding. The new subscription ended up with billing_entity_id NULL and quietly inherited the customer's default at billing time. The billing_entity_id and billing_entity_code params that already work on create were silently ignored on upgrade, so operators couldn't re-bind during an upgrade either.

The same gap was hiding inside pending-subscription promotion: when an operator raised a plan's amount above the previous one (via Plans::UpdateService or Plans::UpdateAmountService), the pending subscription got promoted through PlanUpgradeService, and the binding was lost the same way.

Description

There are two moving parts. The resolution rule (read params, gated by multi_entity_billing, scoped to the customer's organization, not-found error on unknown id/code) is the same one CreateService already had inline. I extracted it into a small shared concern, Subscriptions::Concerns::BillingEntityResolutionConcern, so create and upgrade now share a single source of truth.

The new behaviour on upgrade is straightforward: resolve the entity from params if the flag is on, else carry over current_subscription.billing_entity_id. The carry-over reads the raw FK column rather than the Subscription#billing_entity reader, because the reader would otherwise materialise the customer's current default and freeze it into the new row, collapsing the "inherit at billing time" semantic. The carry-over runs regardless of the feature flag, so an explicit binding made while the flag was on survives even if the flag is later toggled off; on single-entity orgs every FK is NULL, so this branch is a no-op for them.

The two pending-promotion paths (Plans::UpdateService and Plans::UpdateAmountService) needed no code change. They already call PlanUpgradeService, and once upgrade does the carry-over, those inherit the behaviour. Two new specs confirm it end-to-end.

Behaviour by scenario

Scenario Before After
Flag ON, no param, current sub bound to eu new sub billing_entity_id = NULL (binding lost) new sub bound to eu
Flag ON, no param, current sub NULL NULL (inherit customer default at read time) NULL (inherit customer default at read time)
Flag ON, billing_entity_code: us, current sub bound to eu param ignored, new sub NULL new sub bound to us
Flag ON, unknown billing_entity_code param ignored, new sub NULL not_found_failure(resource: "billing_entity")
Flag OFF, current sub bound to eu new sub NULL (binding lost) new sub bound to eu (carry-over independent of flag)
Pending-promotion via Plans::UpdateService / UpdateAmountService new sub NULL new sub keeps previous binding

How to try locally

Spin up a customer on an org with multi_entity_billing enabled and at least two billing entities. Call them eu and us.

  1. Create a subscription bound to eu:
    curl -X POST http://localhost:3000/api/v1/subscriptions \
      -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
      -d '{"subscription":{"external_customer_id":"cust_1","external_id":"sub_1","plan_code":"starter","billing_entity_code":"eu"}}'
  2. Upgrade the plan with no billing_entity_* in the body:
    curl -X POST http://localhost:3000/api/v1/subscriptions \
      -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
      -d '{"subscription":{"external_customer_id":"cust_1","external_id":"sub_1","plan_code":"growth"}}'
  3. Check the new active subscription's billing_entity_id. It now matches the previous one. Before this change it would have been NULL, and the next invoice would have been numbered under the customer's default entity.

To exercise the explicit-override path, replace the second body with "billing_entity_code":"us". The new subscription binds to us, and the termination invoice for the previous subscription stays under eu.

The full test suite for the change:

lago exec api bundle exec rspec \
  spec/services/subscriptions/plan_upgrade_service_spec.rb \
  spec/services/subscriptions/create_service_spec.rb \
  spec/services/plans/update_service_spec.rb \
  spec/services/plans/update_amount_service_spec.rb

226 examples, 0 failures.

aquinofb added 3 commits May 12, 2026 12:08
## Context

When subscriptions gained the ability to target a specific billing
entity, the upgrade path was overlooked. Upgrading a subscription that
was explicitly bound to a non-default entity silently dropped the
binding, leaving the new subscription's billing_entity_id NULL and
falling back to the customer's default at billing time. The same
regression affected indirect upgrade callers in Plans::UpdateService
and Plans::UpdateAmountService when promoting pending subscriptions.

## Description

Extract resolve_billing_entity into a shared concern used by both
Subscriptions::CreateService and Subscriptions::PlanUpgradeService.
On upgrade the resolution order is: explicit billing_entity_id /
billing_entity_code in params (when multi_entity_billing is enabled),
else the previous subscription's raw billing_entity_id, else NULL.

The carry-over from the previous subscription is independent of the
feature flag. Single-entity orgs are unaffected because the FK is NULL
for everyone, and an explicit binding made while the flag was on is
preserved across upgrades even if the flag is later toggled off.

The carry-over uses the raw billing_entity_id column rather than the
Subscription#billing_entity reader, which would otherwise materialise
the customer's current default and collapse the "inherit at billing
time" semantic into a hardcoded snapshot.
## Context

Comments added in the previous commit duplicated what tests and the
self-evident parameter names already convey.

## Description

Remove the two block comments explaining the flag-independent fallback
and the raw-FK choice. The "Flag OFF carries over" spec and the
fallback_id parameter name carry the same meaning.
## Context

The shared concern previously accepted a fallback_id parameter and ran
a SELECT to load the BillingEntity record for the upgrade carry-over
path. The carry-over is a one-caller concept (only PlanUpgradeService
has a previous subscription to carry from), and the loaded record was
only used to read back its own primary key.

## Description

Remove fallback_id from the concern so it owns just the param-based
resolution shared by CreateService and PlanUpgradeService. Move the
carry-over to PlanUpgradeService, assigning billing_entity_id directly
on the new subscription. This eliminates a database round-trip per
upgrade when the previous subscription was bound to an entity and
keeps the concern's surface aligned with what is genuinely shared.
@aquinofb aquinofb changed the title fix(subscriptions): keep billing_entity on upgrade feat(multi-billing-entity): support billing entity on subscription upgrade May 13, 2026
@aquinofb aquinofb marked this pull request as ready for review May 13, 2026 11:31
@aquinofb aquinofb self-assigned this May 13, 2026
Comment thread app/services/subscriptions/plan_upgrade_service.rb
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.

2 participants