feat(multi-billing-entity): move subscription#5498
Open
aquinofb wants to merge 4 commits into
Open
Conversation
## Context Multi-entity billing (Decision 5.6) makes subscription.billing_entity_id mutable. Operators must be able to move an active subscription between billing entities without recreating it. Future invoices follow the new entity; past invoices remain stamped with their original entity. ## Description Extends Subscriptions::UpdateService to accept billing_entity_id and billing_entity_code, gated behind the multi_entity_billing feature flag. Resolution mirrors the create path and lives inside the service: by-id takes precedence over by-code, unknown values fail with not_found_failure!, and the param is silently ignored when the flag is off. Migrates the invoice and fee write sites to prefer subscription.billing_entity over customer.billing_entity, falling back through the column to preserve backwards compatibility for the NULL rows that exist today. This makes the "next billing cycle uses the new entity" guarantee hold end-to-end: the generated invoice row, its sequential id, and the fees on it now all draw from the subscription's binding when set. The one read site that looks up a previous subscription's already-paid fixed-charge fee continues to scope by customer.billing_entity, because legacy rows were written that way and the lookup must match them. Adds a guard in Invoices::SubscriptionService that refuses to invoice a batch whose subscriptions resolve to different billing entities, rather than silently producing an invoice whose header and fees disagree. The proper batching split lives upstream in OrganizationBillingService and is out of scope here.
## Context The acceptance scenario is "active subscription under US, update to EU, next invoice numbered under EU, past invoice unchanged." Existing coverage hit each clause separately (UpdateService spec for the write, SubscriptionService spec for invoice stamping) but no single test proved the seams compose: that the column written by UpdateService is the same column read by the next invoice generation, and that the existing invoice's stamp survives the mutation. ## Description Adds one end-to-end example in subscription_service_spec.rb that generates a billing-cycle invoice, calls Subscriptions::UpdateService to move the subscription to a new entity, generates the next cycle's invoice, and asserts: the new invoice (header and fees) carries the new entity, the past invoice's billing_entity_id is unchanged.
267498d to
1610823
Compare
## Context The first pass added a 7-line comment above `mixed_billing_entities?` that narrated migration history and predicted future work. Those notes belong in the PR description, not in code where they rot. ## Description Drops the comment and inlines the one-call helper `resolved_billing_entity_ids` into the predicate. Also collapses a redundant `subscription.organization.reload` plus its multiline before-block in the UpdateService spec; `organization.update!` mutates the same instance held by the factory, so the reload was load-bearing only as a workaround for a confusion the simpler one-liner avoids.
1610823 to
e33178a
Compare
toommz
reviewed
May 13, 2026
## Context PR feedback from @toommz on PR #5498. Three changes plus a missing test assertion. ## Description Extracts `Subscription#applicable_billing_entity_id`, mirroring the `applicable_*` convention from Customer. The five call sites (four fee writers and the `mixed_billing_entities?` predicate in the invoice service) now read at a glance instead of repeating `billing_entity_id || customer.billing_entity_id` inline. Simplifies `Subscriptions::UpdateService#resolve_billing_entity` by dropping the `attrs` intermediate and calling `find` / `find_by!` directly per branch; both raise the same `ActiveRecord::RecordNotFound` that the rescue handles. Renames the local `resolved` to `new_billing_entity` at the call site for clarity. Adds a negative assertion in the unknown-billing_entity_id context that the `subscription.updated` webhook does not fire when the resolver raises. This locks in that `not_found_failure!` short-circuits before the transaction reaches `subscription.save!`.
toommz
approved these changes
May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Operators can now move an active subscription to a different billing entity through a normal
PATCH /api/v1/subscriptions/:external_idrequest, instead of having to terminate and recreate it. From the next billing cycle on, invoices for that subscription land under the new entity (using its number prefix and sequence); invoices already generated stay where they were.The change is gated behind the
multi_entity_billingfeature flag, so single-entity organizations see no behavior change at all.What changed
PATCHwithbilling_entity_idorbilling_entity_codecustomer.billing_entitysubscription.billing_entity(falls back to customer's)subscription.customer.billing_entity_idsubscription.billing_entity_id(falls back to customer's)NULLbilling_entity_id(every row in prod today)mixed_billing_entitiesvalidation failure; never produces a mixed invoiceHow the resolver works
Subscriptions::UpdateServiceresolves the entity inline. It accepts either an id or a code on the same request; if both are sent, the id wins. Unknown values return a 404 withbilling_entity_not_found. Cross-organization references resolve to "not found" because the lookup is scoped to the subscription's organization.For the read side, every fee and invoice writer now prefers
subscription.billing_entity_id || subscription.customer.billing_entity_id. Because every existing subscription row has aNULLoverride, the fallback path is taken and the behavior is identical to today. Once an operator flips the column, the next invoice and its fees follow.The one read site that intentionally stays on
subscription.customer.billing_entityis the previous-period proration lookup inFees::FixedChargeService, because legacy paid fees were written against the customer's entity and the query needs to match them.Out of scope
The
subscription.updatedwebhook payload doesn't surface the newbilling_entity_idyet; that lives with the serializer and OpenAPI work. Batched billing across mixed-entity subscriptions returns a validation error today; the proper fix is to split the batch upstream inOrganizationBillingServiceand is deferred.Test plan
multi_entity_billinginfeature_flags, plus two billing entitiesus(default) andeu. Seed a customer underusand an active subscription on a monthly recurring plan.PATCH /api/v1/subscriptions/:external_idwith{ subscription: { billing_entity_code: "eu" } }. Expect200and verify thesubscriptionsrow'sbilling_entity_idnow points ateu; thecustomersrow is unchanged.BillSubscriptionJobdirectly for the subscription). Verify the resulting invoice'sbilling_entity_idiseu, its number draws fromeu's sequence and prefix, and any previously-generated invoices are untouched.multi_entity_billingoff on the organization.PATCHthe subscription again withbilling_entity_code: "us". Expect200and no change to the row.PATCHwithbilling_entity_idset to a random UUID. Expect404withcode: "billing_entity_not_found".PATCHwithbilling_entity_idreferencing an entity from a different organization. Expect the same.PATCHit toeu, leaving the original onus. Trigger their next billing day. Expect a validation failure withbilling_entity: ["mixed_billing_entities"]rather than a mixed invoice.