Skip to content

feat(licenses): add scalable license transitions - #2306

Merged
johnyeocx merged 12 commits into
devfrom
license-billing-polish
Jul 20, 2026
Merged

feat(licenses): add scalable license transitions#2306
johnyeocx merged 12 commits into
devfrom
license-billing-polish

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • transition license assignment products, entitlements, cycles, and base prices through bounded background batches
  • preserve license quantities, billing state, Stripe linkage, and assignment usage across immediate and scheduled changes
  • improve license customization UI, quantity handling, transition previews, and Option-gated admin metadata
  • add integration coverage for license billing and transition edge cases

Validation

  • commit hooks: knip
  • server TypeScript build
  • Vite TypeScript build
  • focused transition integration tests run during development

Full integration suite was not run for the final UI-only changes.


Summary by cubic

Adds scalable, row-batched license transitions that preserve quantities, billing state, and Stripe linkage across immediate and scheduled plan changes. Also introduces license parent propagation with customization carryover, and polishes previews, Stripe item updates, and validation.

  • New Features

    • Batch transition engine: row-batched SQL for base price and entitlement add/replace/remove, cycle alignment on base price adds, assignment/definition ceilings, detailed logs, and background batchTransitionTask.
    • Successor matching: pairs licenses by plan id/group and mirrors transitions in memory for previews and Stripe; seats price through projected definitions.
    • License parent propagation: preview/resolve targets and apply updates to parent plans while preserving child customizations; catalog preflight and updates support parent selection.
  • Bug Fixes

    • Prevent immediate swaps that would strand active assignments; enforce pool capacity vs. live seats and distinct definition limits.
    • Align assignment entitlement cycles on base price additions; seat queries use effective parent status.
    • Billing polish: merge line items per price, correct unused-prepaid quantities, include scheduled paid-recurring in next-cycle previews, and set/remove Stripe subscription ids on patched/updated products while excluding orphan recurring items.

Written for commit 1e1bc59. Summary will update on new commits.

Review in cubic

@johnyeocx
johnyeocx requested a review from ay-rod as a code owner July 20, 2026 13:26
@entelligence-ai-pr-reviews

Copy link
Copy Markdown
Contributor

Automatic Review Skipped

Too many files for automatic review.

If you would still like a review, you can trigger one manually by commenting:

@entelligence review

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (262 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@capy-ai

capy-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

26 issues found across 262 files

Confidence score: 2/5

  • server/src/internal/product/actions/deleteProduct.ts can still delete customer license pools when a license is tied to a retired/custom catalog link, which creates a concrete data-loss path if merged as-is — extend the delete guard to include retired/custom plan_license rows (or explicitly block deletion when customer references exist) before merging.
  • Transition logic across server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts, server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts, shared/utils/productUtils/entUtils/findEntitlement/findEntitlementSuccessor.ts, and server/src/internal/billing/v2/compute/customerLicenseTransitions/pairCustomerLicensesByLicensePlan.ts can mis-pair pools, skip priced seat updates, and drop assigned-seat charges, causing incorrect billing and entitlement assignment state — key projection/matching by stable license IDs, keep a priced-entitlement repoint path, and filter dangling rows before merge.
  • vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts now emits a combined prepaid + seat-total mutation that the server rejects with 400, so user updates can fail entirely in a common edit flow — split these mutations client-side (or add server support for both fields together) before shipping.
  • State consistency issues in server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields.ts, server/src/internal/billing/v2/actions/batchTransition/execute/executeCustomerEntitlementOperations.ts, server/src/internal/billing/v2/actions/batchTransition/compute/operations/customerEntitlementCycleOperations/computeCustomerEntitlementCycleOperations.ts, and server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts can null entity scope, update the wrong parent product, keep outgoing reset schedules, or leave scheduled add-ons after cancellation — tighten entity-scoped filters/cycle computation and verify webhook cleanup with targeted transition + cancellation integration tests.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts">

<violation number="1" location="vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts:244">
P1: Updating a prepaid option and a license-seat total in one submission now returns 400 instead of applying either change. Split these mutations or add server-side support before emitting both request fields together.</violation>
</file>

<file name="server/src/internal/products/prices/repos/listDistinctBasePricesByCustomerLicense.ts">

<violation number="1" location="server/src/internal/products/prices/repos/listDistinctBasePricesByCustomerLicense.ts:31">
P2: Transitions can reject valid assigned-seat changes when released pool seats retain more than `MAX_DISTINCT_BASE_PRICES` historical prices. Filter this candidate query to assigned seats so its limit and computed operations match batch execution scope.</violation>
</file>

<file name="vite/src/views/customers2/components/sheets/ReadOnlyPlanItems.tsx">

<violation number="1" location="vite/src/views/customers2/components/sheets/ReadOnlyPlanItems.tsx:52">
P2: Prepaid features with a zero assigned quantity render without a quantity badge, making `0` indistinguishable from no quantity. Render the badge when `prepaidQuantity != null` rather than using its truthiness.</violation>
</file>

<file name="vite/src/views/products/plan/versioning/buildMigrateTargets.ts">

<violation number="1" location="vite/src/views/products/plan/versioning/buildMigrateTargets.ts:143">
P2: The review preview labels historical parent versions with customers as a new version, although parent propagation only versions the latest parent entry. Base this badge/version number on `entry.versionable` so the confirmation accurately reflects the update.</violation>
</file>

<file name="server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields.ts">

<violation number="1" location="server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields.ts:67">
P1: New license assignments lose their entitlement entity scope because this always writes `null`, even when `internalEntityId` identifies the assigned entity. Preserve the same resolved entity ID used by `initCustomerProduct` so assignment entitlements remain entity-stamped.</violation>
</file>

<file name="server/src/internal/billing/v2/compute/customerLicenseTransitions/pairCustomerLicensesByLicensePlan.ts">

<violation number="1" location="server/src/internal/billing/v2/compute/customerLicenseTransitions/pairCustomerLicensesByLicensePlan.ts:30">
P1: A dead outgoing license can suppress a valid group successor match. Filter rows without `planLicense` before matching, so dangling rows cannot claim an incoming candidate and cause its usage/link state to be lost during this transition.</violation>
</file>

<file name="vite/src/views/customers2/components/sheets/LicenseAssignedEntities.tsx">

<violation number="1" location="vite/src/views/customers2/components/sheets/LicenseAssignedEntities.tsx:102">
P3: Opening a pool can briefly state “No entities assigned” before `list_assignments` returns, even when assignments exist. Surface a loading state until the assignments query completes instead of treating its initial empty array as a confirmed empty result.</violation>
</file>

<file name="vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx">

<violation number="1" location="vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx:79">
P2: A license-only outgoing plan now enables diff mode but hides newly added parent-plan items: the item baseline falls back to the incoming template when `outgoingItems` is empty. Use `hasOutgoingPlans` to retain the empty outgoing baseline for any transition.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/batchTransition/execute/executeCustomerEntitlementOperations.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/batchTransition/execute/executeCustomerEntitlementOperations.ts:141">
P1: Adding an entitlement also updates the entity-less parent `customer_product`, rather than only license assignments. Filter add candidates to `seat.internal_entity_id IS NOT NULL`, matching replacement/removal and base-price additions.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/batchTransition/compute/operations/customerEntitlementCycleOperations/computeCustomerEntitlementCycleOperations.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/batchTransition/compute/operations/customerEntitlementCycleOperations/computeCustomerEntitlementCycleOperations.ts:26">
P1: A base-price addition combined with an entitlement replacement that changes reset behavior leaves assignments with the outgoing entitlement’s reset schedule. Compute cycle eligibility/anchor/end from the replacement target (including nulling fields when it becomes non-resetting), or apply cycle alignment after replacements.</violation>
</file>

<file name="server/src/internal/product/actions/deleteProduct.ts">

<violation number="1" location="server/src/internal/product/actions/deleteProduct.ts:87">
P0: Deleting a license whose catalog link was retired can still delete customer license pools. Include custom/retired `plan_license` rows in this guard (or separately reject those with customer references) before deleting the product.</violation>
</file>

<file name="server/src/internal/licenses/actions/propagation/prepareLicenseParentPropagation.ts">

<violation number="1" location="server/src/internal/licenses/actions/propagation/prepareLicenseParentPropagation.ts:73">
P2: Updating a child with many license parents can fan out all plan/license resolution work at once, exhausting request/DB capacity before propagation starts. Process `contexts` in bounded chunks or with a concurrency limiter rather than one unbounded `Promise.all`.</violation>
</file>

<file name="vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx">

<violation number="1" location="vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx:47">
P3: A seat-only license change is saved but remains visually uncustomized: the section title omits its customization indicator and both license/item sections receive `showDiff={false}`. Include staged `licenseQuantities` that differ from `existingLicenseQuantities` when deriving `isCustomized`.</violation>
</file>

<file name="server/src/internal/licenses/repos/planLicenseRepo.ts">

<violation number="1" location="server/src/internal/licenses/repos/planLicenseRepo.ts:120">
P2: Editing a license with many customer-specific links can load and rewrite every snapshot in one request, causing timeouts or transaction exhaustion. Return bounded pages (or enqueue batches) and let the materialization flow process a cursor at a time.</violation>
</file>

<file name="server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts">

<violation number="1" location="server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerLicenseToStripeItemSpecs.ts:39">
P1: A transition of one duplicate license pool can drop assigned-seat charges for its untransitioned sibling pool. Track projection state by `customerLicense.id` (or only select projected rows when this pool has them), rather than treating a plan-license ID as a pool-specific projection marker.</violation>
</file>

<file name="server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts">

<violation number="1" location="server/src/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions.ts:54">
P1: Seat entitlement transitions with a price now complete without updating those assigned seats. `batchTransition` explicitly leaves priced entitlement transitions unhandled, so retain a repoint path for them or implement their batch operation before replacing the enqueue.</violation>
</file>

<file name="server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts">

<violation number="1" location="server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts:34">
P1: Canceled subscriptions can leave scheduled add-ons behind: this filter skips them, and later cleanup only deletes a scheduled main product. Preserve cleanup for scheduled non-main products (and their schedule state) so they cannot activate after the subscription was deleted.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/batchTransition/execute/sql/deleteCustomerBasePricesBatch.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/batchTransition/execute/sql/deleteCustomerBasePricesBatch.ts:29">
P2: A concurrent assignment detach or license-link/status update can leave this batch deleting a base price from a seat that no longer matches this transition. Lock the `seat` rows alongside `customer_price` so the qualifying assignment cannot change between candidate selection and deletion.</violation>
</file>

<file name="shared/utils/productUtils/entUtils/findEntitlement/findEntitlementSuccessor.ts">

<violation number="1" location="shared/utils/productUtils/entUtils/findEntitlement/findEntitlementSuccessor.ts:43">
P1: A feature-only fallback can steal a target that is an exact match for a later entitlement, making transitions depend on entitlement array order. This swaps customer assignments and applies incorrect balance patches when one reset interval changes alongside another unchanged entitlement for the same feature; reserve exact matches globally before lower-precision matching.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/batchTransition/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/batchTransition/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts:55">
P1: A changed definition that retains its entitlement ID produces a replacement whose source contains its target ID, causing every such license transition to throw `Batch replacement requires different outgoing and incoming entitlement IDs`. Exclude the incoming ID unconditionally, matching base-price replacement behavior.</violation>
</file>

<file name="vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx">

<violation number="1" location="vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx:317">
P2: Adding or removing a paid license still shows “No changes to billing will be made.” `licenseRowHasBillingChanges` ignores added/removed rows because they lack one comparison side; include priced license additions/removals in this predicate so the billing status matches the update.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/batchTransition/execute/sql/repointLicenseCustomerProductsBatch.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/batchTransition/execute/sql/repointLicenseCustomerProductsBatch.ts:37">
P2: Transitioned assignments retain the outgoing public `product_id`, so flows that identify existing customer products by plan ID can treat them as the old license product. Update `product_id` alongside `internal_product_id` using the incoming product definition.</violation>
</file>

<file name="server/src/internal/product/actions/previewUpdatePlan/previewAffectedLicenseParents.ts">

<violation number="1" location="server/src/internal/product/actions/previewUpdatePlan/previewAffectedLicenseParents.ts:128">
P2: Unselected parent previews show propagated `plan_changes` even though the update preserves their effective license plan. Use `currentEffectivePlan` unless this parent target is selected, so `license_changes` matches the eventual preservation rebase.</violation>

<violation number="2" location="server/src/internal/product/actions/previewUpdatePlan/previewAffectedLicenseParents.ts:166">
P2: Unselected latest parents are reported `versionable: true` whenever a child version changes and they have customers, although their preservation update is always in-place. Gate this calculation on selection so migration UI does not offer a version that update execution will not create.</violation>
</file>

<file name="vite/src/components/forms/shared/plan-items/PlanLicensesSummary.tsx">

<violation number="1" location="vite/src/components/forms/shared/plan-items/PlanLicensesSummary.tsx:269">
P1: An alternate-currency-only license price edit is classified as no billing change because `additional_currencies` is omitted from the fingerprint. Include it so updates cannot incorrectly enable the no-billing-change path.</violation>

<violation number="2" location="vite/src/components/forms/shared/plan-items/PlanLicensesSummary.tsx:380">
P2: License cadence-only changes show no pricing transition when the amount is unchanged. Include interval and interval_count in this display condition and render the old/new cadence, matching the parent plan price preview.</violation>
</file>

Note: This PR contains a large number of files. cubic only reviews up to 200 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.

Re-trigger cubic

orgId: org.id,
env,
}),
planLicenseRepo.listCatalogByLicenseInternalProductIds({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0: Deleting a license whose catalog link was retired can still delete customer license pools. Include custom/retired plan_license rows in this guard (or separately reject those with customer references) before deleting the product.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/product/actions/deleteProduct.ts, line 87:

<comment>Deleting a license whose catalog link was retired can still delete customer license pools. Include custom/retired `plan_license` rows in this guard (or separately reject those with customer references) before deleting the product.</comment>

<file context>
@@ -71,8 +84,18 @@ export const deleteProduct = async ({
 			orgId: org.id,
 			env,
 		}),
+		planLicenseRepo.listCatalogByLicenseInternalProductIds({
+			db,
+			licenseInternalProductIds: deletionInternalProductIds,
</file context>

return {
...base,
options: options.length > 0 ? options : undefined,
license_quantities: convertLicenseQuantitiesToParams({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Updating a prepaid option and a license-seat total in one submission now returns 400 instead of applying either change. Split these mutations or add server-side support before emitting both request fields together.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts, line 244:

<comment>Updating a prepaid option and a license-seat total in one submission now returns 400 instead of applying either change. Split these mutations or add server-side support before emitting both request fields together.</comment>

<file context>
@@ -219,6 +241,9 @@ export function useUpdateSubscriptionRequestBody({
 		return {
 			...base,
 			options: options.length > 0 ? options : undefined,
+			license_quantities: convertLicenseQuantitiesToParams({
+				licenseQuantities: changedLicenseQuantities,
+			}),
</file context>

return {
internal_customer_id: initContext.fullCustomer.internal_id,
internal_feature_id: entitlement.internal_feature_id,
internal_entity_id: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: New license assignments lose their entitlement entity scope because this always writes null, even when internalEntityId identifies the assigned entity. Preserve the same resolved entity ID used by initCustomerProduct so assignment entitlements remain entity-stamped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields.ts, line 67:

<comment>New license assignments lose their entitlement entity scope because this always writes `null`, even when `internalEntityId` identifies the assigned entity. Preserve the same resolved entity ID used by `initCustomerProduct` so assignment entitlements remain entity-stamped.</comment>

<file context>
@@ -0,0 +1,85 @@
+	return {
+		internal_customer_id: initContext.fullCustomer.internal_id,
+		internal_feature_id: entitlement.internal_feature_id,
+		internal_entity_id: null,
+		feature_id: entitlement.feature.id,
+		customer_id: initContext.fullCustomer.id,
</file context>

Comment on lines +30 to +33
const { matches } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: A dead outgoing license can suppress a valid group successor match. Filter rows without planLicense before matching, so dangling rows cannot claim an incoming candidate and cause its usage/link state to be lost during this transition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/compute/customerLicenseTransitions/pairCustomerLicensesByLicensePlan.ts, line 30:

<comment>A dead outgoing license can suppress a valid group successor match. Filter rows without `planLicense` before matching, so dangling rows cannot claim an incoming candidate and cause its usage/link state to be lost during this transition.</comment>

<file context>
@@ -16,60 +17,37 @@ export type CustomerLicensePair = {
-	}
-
-	return pairs;
+	const { matches } = matchCustomerLicenseSuccessors({
+		outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
+		incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
</file context>
Suggested change
const { matches } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
});
const { matches } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: (
outgoingCustomerProduct.customer_licenses ?? []
).filter((customerLicense) => customerLicense.planLicense),
incomingCustomerLicenses: (
incomingCustomerProduct.customer_licenses ?? []
).filter((customerLicense) => customerLicense.planLicense),
});

if (operation.type === "add") {
return {
replaced: 0,
added: await executeAddition({ ctx, batchTransition, operation }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Adding an entitlement also updates the entity-less parent customer_product, rather than only license assignments. Filter add candidates to seat.internal_entity_id IS NOT NULL, matching replacement/removal and base-price additions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/actions/batchTransition/execute/executeCustomerEntitlementOperations.ts, line 141:

<comment>Adding an entitlement also updates the entity-less parent `customer_product`, rather than only license assignments. Filter add candidates to `seat.internal_entity_id IS NOT NULL`, matching replacement/removal and base-price additions.</comment>

<file context>
@@ -0,0 +1,194 @@
+	if (operation.type === "add") {
+		return {
+			replaced: 0,
+			added: await executeAddition({ ctx, batchTransition, operation }),
+			removed: 0,
+		};
</file context>

Comment on lines +166 to +172
const versionable =
(isLatest && data.force_version === true) ||
(isLatest &&
!data.disable_version &&
!data.all_versions &&
hasCustomers &&
hasChanges);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Unselected latest parents are reported versionable: true whenever a child version changes and they have customers, although their preservation update is always in-place. Gate this calculation on selection so migration UI does not offer a version that update execution will not create.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/product/actions/previewUpdatePlan/previewAffectedLicenseParents.ts, line 166:

<comment>Unselected latest parents are reported `versionable: true` whenever a child version changes and they have customers, although their preservation update is always in-place. Gate this calculation on selection so migration UI does not offer a version that update execution will not create.</comment>

<file context>
@@ -0,0 +1,214 @@
+			const hasChanges =
+				licenseChange.previous_attributes !== null ||
+				licenseChange.plan_changes !== null;
+			const versionable =
+				(isLatest && data.force_version === true) ||
+				(isLatest &&
</file context>
Suggested change
const versionable =
(isLatest && data.force_version === true) ||
(isLatest &&
!data.disable_version &&
!data.all_versions &&
hasCustomers &&
hasChanges);
const versionable =
selectedTargets.has(
licenseParentTargetKey({
planId: parent.id,
version: parent.version,
}),
) &&
((isLatest && data.force_version) ||
(isLatest &&
!data.disable_version &&
!data.all_versions &&
hasCustomers &&
hasChanges));

Comment on lines +128 to +133
const targetEffectivePlan = currentLink.customized
? applyLicenseCustomizeToBasePlan({
basePlan: editedChildPlan,
customize: currentCustomize,
})
: editedChildPlan;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Unselected parent previews show propagated plan_changes even though the update preserves their effective license plan. Use currentEffectivePlan unless this parent target is selected, so license_changes matches the eventual preservation rebase.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/product/actions/previewUpdatePlan/previewAffectedLicenseParents.ts, line 128:

<comment>Unselected parent previews show propagated `plan_changes` even though the update preserves their effective license plan. Use `currentEffectivePlan` unless this parent target is selected, so `license_changes` matches the eventual preservation rebase.</comment>

<file context>
@@ -0,0 +1,214 @@
+				from: currentChildPlan,
+				to: currentEffectivePlan,
+			});
+			const targetEffectivePlan = currentLink.customized
+				? applyLicenseCustomizeToBasePlan({
+						basePlan: editedChildPlan,
</file context>
Suggested change
const targetEffectivePlan = currentLink.customized
? applyLicenseCustomizeToBasePlan({
basePlan: editedChildPlan,
customize: currentCustomize,
})
: editedChildPlan;
const isSelected = selectedTargets.has(
licenseParentTargetKey({
planId: parent.id,
version: parent.version,
}),
);
const targetEffectivePlan = isSelected
? currentLink.customized
? applyLicenseCustomizeToBasePlan({
basePlan: editedChildPlan,
customize: currentCustomize,
})
: editedChildPlan
: currentEffectivePlan;

showDiff &&
!isRemoved &&
row.previousItems !== undefined &&
(previousPrice?.price ?? null) !== (currentPrice?.price ?? null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: License cadence-only changes show no pricing transition when the amount is unchanged. Include interval and interval_count in this display condition and render the old/new cadence, matching the parent plan price preview.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vite/src/components/forms/shared/plan-items/PlanLicensesSummary.tsx, line 380:

<comment>License cadence-only changes show no pricing transition when the amount is unchanged. Include interval and interval_count in this display condition and render the old/new cadence, matching the parent plan price preview.</comment>

<file context>
@@ -86,84 +116,312 @@ export const diffPlanLicenses = ({
+					showDiff &&
+					!isRemoved &&
+					row.previousItems !== undefined &&
+					(previousPrice?.price ?? null) !== (currentPrice?.price ?? null);
+
 				return (
</file context>

/>
</div>
)}
{filteredAssignments.length === 0 ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Opening a pool can briefly state “No entities assigned” before list_assignments returns, even when assignments exist. Surface a loading state until the assignments query completes instead of treating its initial empty array as a confirmed empty result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vite/src/views/customers2/components/sheets/LicenseAssignedEntities.tsx, line 102:

<comment>Opening a pool can briefly state “No entities assigned” before `list_assignments` returns, even when assignments exist. Surface a loading state until the assignments query completes instead of treating its initial empty array as a confirmed empty result.</comment>

<file context>
@@ -0,0 +1,182 @@
+					/>
+				</div>
+			)}
+			{filteredAssignments.length === 0 ? (
+				<p className="text-sm text-tertiary-foreground">
+					{poolAssignments.length === 0
</file context>

);
const licenseQuantityEditor = {
form,
quantities: licenseQuantities,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: A seat-only license change is saved but remains visually uncustomized: the section title omits its customization indicator and both license/item sections receive showDiff={false}. Include staged licenseQuantities that differ from existingLicenseQuantities when deriving isCustomized.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx, line 47:

<comment>A seat-only license change is saved but remains visually uncustomized: the section title omits its customization indicator and both license/item sections receive `showDiff={false}`. Include staged `licenseQuantities` that differ from `existingLicenseQuantities` when deriving `isCustomized`.</comment>

<file context>
@@ -31,6 +35,26 @@ export function EditPlanSection() {
+	);
+	const licenseQuantityEditor = {
+		form,
+		quantities: licenseQuantities,
+		existingQuantities: existingLicenseQuantities,
+	};
</file context>

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
checkout Ignored Ignored Jul 20, 2026 2:05pm
landing-page Ignored Ignored Jul 20, 2026 2:05pm

Request Review

@vercel
vercel Bot temporarily deployed to Preview – autumn-vite July 20, 2026 14:05 Inactive
@johnyeocx
johnyeocx merged commit 68c2726 into dev Jul 20, 2026
16 checks passed
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.

1 participant