Skip to content

feat(multi-billing-entity): support billing entity on subscription downgrade#5504

Open
aquinofb wants to merge 1 commit into
ing-119from
ing-120
Open

feat(multi-billing-entity): support billing entity on subscription downgrade#5504
aquinofb wants to merge 1 commit into
ing-119from
ing-120

Conversation

@aquinofb
Copy link
Copy Markdown
Contributor

@aquinofb aquinofb commented May 13, 2026

Context

The earlier feature work introduced support for binding a subscription to a non-default billing entity on creation, but the downgrade path was not part of that initial scope. A subscription explicitly bound to a non-default entity therefore loses that binding the moment a downgrade is requested: the pending row created for the next billing period is unbound, and the billing_entity_id / billing_entity_code params that work on the create endpoint are silently dropped on downgrade. Operators can neither preserve an existing binding nor re-bind a pending subscription to a different entity in one call.

Description

This is the same rule the create path applies, transplanted onto the downgrade path. The resolution order is: explicit params win; otherwise carry the parent subscription's binding over to the pending row; otherwise persist nil so the customer's default fires at billing time. The same rule applies when re-downgrading a not-yet-active subscription via update_pending_subscription, which only touches the column when params explicitly carry an entity reference.

One nuance worth calling out: the carry-over from the parent subscription is not gated by the multi_entity_billing flag. That's deliberate. Single-entity customers all have NULL bindings today, so the carry-over is a no-op for them. For multi-entity organizations, it means an existing binding survives even if someone later disables the flag at the org level.

Scenario Before After
Downgrade, no param, parent bound to X Pending row unbound; re-resolves to customer.billing_entity at activation Pending row bound to X
Downgrade, billing_entity_code: Y, parent bound to X Param silently dropped; pending row unbound Pending row bound to Y; parent keeps X until its terminal invoice
Downgrade, unknown entity code Param silently dropped; pending row unbound 404 billing_entity_not_found
Re-downgrade of starting_in_the_future sub with billing_entity_code: Y Param silently dropped; pending row keeps original entity Pending row re-bound to Y
multi_entity_billing OFF, parent bound to X Pending row unbound Pending row bound to X (unconditional carry-over)

Stacked on #5503, which extracted the resolution helper into the shared Subscriptions::Concerns::BillingEntityResolutionConcern reused here.

Two things worth flagging for the reviewer. Invoice numbering at activation (gapless per (customer, billing_entity)) is satisfied by construction: the pending row's billing_entity_id is now correctly set, and the existing invoice generation path reads subscription.billing_entity || customer.billing_entity at billing time, so no new invoice-level assertions were added here. Separately, Subscriptions::PlanUpgradeService#update_pending_subscription has the same gap on the upgrade side: it does not propagate billing_entity when an operator upgrades a not-yet-active pending subscription. That parity gap is out of scope here and worth a follow-up.

How to try locally

Enable the multi_entity_billing feature flag on your test organization, then:

  1. Create a non-default billing entity (POST /api/v1/billing_entities with code: "eu").
  2. Create a subscription against a higher-priced plan with billing_entity_code: "eu". Confirm the subscription row has billing_entity_id set.
  3. Downgrade to a cheaper plan without passing billing_entity_*. Confirm the pending next_subscription carries billing_entity_id = eu.
  4. Downgrade that same pending sub again with billing_entity_code: "us". Confirm the pending row now points to us (the update_pending_subscription branch).
  5. Downgrade with an unknown code. Confirm 404 billing_entity_not_found.

The tests for these scenarios live under describe "billing entity binding on downgrade" in spec/services/subscriptions/create_service_spec.rb:

bundle exec rspec spec/services/subscriptions/create_service_spec.rb -e "billing entity binding on downgrade"

@aquinofb aquinofb marked this pull request as draft May 13, 2026 11:09
@aquinofb aquinofb changed the title fix(multi-billing-entity): preserve entity on subscription downgrade feat(multi-billing-entity): support billing entity on subscription downgrade May 13, 2026
…wngrade

## Context

The earlier feature work introduced support for binding a subscription
to a non-default billing entity on creation, but the downgrade path
was not part of that initial scope. As a result, a subscription
explicitly bound to a non-default entity loses that binding the moment
a downgrade is requested: the pending row created for the next billing
period is unbound, and the billing entity parameters accepted on
create are silently dropped on downgrade, so operators cannot re-bind
the pending subscription in one call.

## Description

Apply the same billing-entity resolution rule on the downgrade path
that already runs on create: explicit params win; otherwise carry the
parent subscription's binding over to the pending row; otherwise
persist nil to keep the "inherit from customer at billing time"
semantic. The same rule applies when re-downgrading a not-yet-active
subscription via update_pending_subscription, which only writes the
column when params explicitly carry an entity reference.

Carry-over from the parent is not gated by the multi_entity_billing
flag. This is a no-op for single-entity customers, whose subscriptions
all have NULL bindings today, but it lets multi-entity organizations
keep their existing bindings even if the feature is later disabled at
the org level.
@aquinofb aquinofb marked this pull request as ready for review May 13, 2026 14:03
@aquinofb aquinofb requested review from a team, lovrocolic and mariohd May 13, 2026 14:03
@aquinofb aquinofb self-assigned this May 13, 2026
ending_at: params.key?(:ending_at) ? params[:ending_at] : current_subscription.ending_at,
progressive_billing_disabled: params[:progressive_billing_disabled] || false
progressive_billing_disabled: params[:progressive_billing_disabled] || false,
billing_entity_id: current_subscription.billing_entity_id
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we double check with the product team if this as a fallback is correct? I am not sure if we should set nil as a fallback so that we inherit billing entity from customer 🤔

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