Skip to content

dev - #2283

Merged
charlietlamb merged 21 commits into
mainfrom
dev
Jul 17, 2026
Merged

dev#2283
charlietlamb merged 21 commits into
mainfrom
dev

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary by cubic

Adds parent-specific license customization and license-aware plan update previews that keep customer pools stable. Also scopes custom license Stripe prices to the parent product, unifies seat-quantity convergence in syncV2, enriches billing.updated, serializes migrations per customer, and pins the matched plan version during sync.

  • New Features

    • Customize licenses per parent with licenses[].customize; plan responses include customize.
    • Create customized fixed license prices under the parent Stripe Product; back-sync resolves the owning parent before shape to avoid cross-parent ambiguity.
    • Customized catalog links rebase when the base license changes; previews surface license_changes with link-level diffs and effective plan changes.
    • syncV2 returns customerLicenseUpdates; billing.updated now includes previous paid quantities for actionable seat-change events.
  • Bug Fixes

    • syncV2 preserves the matched plan version when exact Stripe Price IDs are used, preventing version drift.
    • Plan dashboard resolves the pinned license plan version so versioned license prices display correctly.

Written for commit 940bfad. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR ships two major features: (1) per-link license plan customization — parent plans can now carry their own item/price diff for each linked license, stored in licenseEntitlements/licensePrices junction tables and rebased automatically when the base license product changes; and (2) unification of license seat-quantity sync into the main syncV2 action, removing the separate syncLicenseQuantities action.

  • [Improvements] Per-parent-plan license customization: PlanLicenseParams gains a customize field; syncPlanLicenses applies preserve/clear/replace logic per link; rebaseCatalogPlanLicenses propagates base-product edits onto saved customizations; new Drizzle relations enable ORM loading of custom rows.
  • [Improvements] syncLicenseQuantities absorbed into syncV2/computeSyncImmediatePhase: seat-count drifts detected inline as customerLicenseUpdates; reconcileLicenseStateForCustomer runs inside syncV2 when seat updates are present.
  • [API changes] ApiPlanLicenseV1 and ApiPlanItemV1 gain internal entitlement_id/price_id fields; climbLicenseMatch now returns kind: \"none\" for ambiguous multi-parent cases.

Confidence Score: 3/5

The license-customization and seat-sync-unification paths look solid, but the always-on webhook in syncV2 may cause duplicate billing.updated events for customers on the subscription.updated path.

The license customization and seat-sync unification logic is well-tested and cleanly structured, but the removal of the webhook opt-in guard in syncV2 changes customer-observable behavior: handleStripeSubscriptionUpdated can now emit two separate billing.updated webhooks for a single Stripe event in any case where both a sync and tracked status/removal changes occur together.

server/src/internal/billing/v2/actions/sync/syncV2.ts and server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/autoSyncUpdatedSubscription.ts warrant a second look to confirm the split-webhook behavior is intentional.

Important Files Changed

Filename Overview
server/src/internal/billing/v2/actions/sync/syncV2.ts Removes opt-in webhook emission in favour of always-on; adds reconcileLicenseStateForCustomer after execute and narrows expired_cus_product_ids to status=Expired only. The always-on webhook breaks the prior double-send prevention.
server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts Absorbs the removed syncLicenseQuantities action: detects seat-count drifts and short-circuits to customerLicenseUpdates. The continue skips all further product processing for drifted contexts.
server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/autoSyncUpdatedSubscription.ts Removes trackSyncResult and syncLicenseQuantities calls; passes tags to syncV2. Sync changes no longer flow into the event context, so emitBillingChangeWebhook will no longer bundle them into one webhook.
server/src/internal/licenses/actions/customize/rebaseCatalogPlanLicenses.ts New file: rebases saved per-link customizations when the base license product changes, recomputing and storing the diff against the new base.
server/src/internal/licenses/actions/links/syncPlanLicenses.ts Extended to support per-link item customization (preserve/clear/replace modes); resolves source links and applies custom items inside the transaction.
server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/licenseMatchUtils/findLicenseMatchForStripeItem.ts New file: performs exact then shape-based license link matching for a Stripe item, returning none on ambiguity.
server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/licenseMatchUtils/climbLicenseMatch.ts Updated to handle multiple parent links via shape matching; returns kind:none when ambiguous rather than matching the first link.
shared/db/schema.ts Exports two new Drizzle relation objects (licenseEntitlementRelations, licensePriceRelations) added for the junction tables.
shared/models/licenseModels/planLicenseRelations.ts Adds Drizzle relations for licenseEntitlements and licensePrices junction tables, enabling ORM eager-loading of custom price/ent rows.
server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts Adds plan change entries for customerLicenseUpdates (seat changes), but with empty previous_attributes and item_changes, limiting webhook informativeness.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Stripe
    participant SubUpdated as handleStripeSubscriptionUpdated
    participant AutoSync as autoSyncUpdatedSubscription
    participant SyncV2 as syncV2
    participant EmitCtx as emitBillingChangeWebhook

    Stripe->>SubUpdated: subscription.updated
    SubUpdated->>SubUpdated: syncCustomerProductStatus (tracked to ctx)
    SubUpdated->>SubUpdated: handleStripeSubscriptionCanceled (tracked to ctx)
    SubUpdated->>AutoSync: autoSyncUpdatedSubscription
    AutoSync->>AutoSync: expireRemovedCustomerProducts (tracked to ctx)
    AutoSync->>SyncV2: syncV2(tags sync customer subscription updated)
    SyncV2->>SyncV2: executeAutumnBillingPlan
    SyncV2->>SyncV2: reconcileLicenseStateForCustomer
    SyncV2-->>Stripe: billing.updated 1 sync changes
    AutoSync-->>SubUpdated: return
    SubUpdated->>EmitCtx: emitBillingChangeWebhook
    EmitCtx-->>Stripe: billing.updated 2 ctx-tracked changes if any
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Stripe
    participant SubUpdated as handleStripeSubscriptionUpdated
    participant AutoSync as autoSyncUpdatedSubscription
    participant SyncV2 as syncV2
    participant EmitCtx as emitBillingChangeWebhook

    Stripe->>SubUpdated: subscription.updated
    SubUpdated->>SubUpdated: syncCustomerProductStatus (tracked to ctx)
    SubUpdated->>SubUpdated: handleStripeSubscriptionCanceled (tracked to ctx)
    SubUpdated->>AutoSync: autoSyncUpdatedSubscription
    AutoSync->>AutoSync: expireRemovedCustomerProducts (tracked to ctx)
    AutoSync->>SyncV2: syncV2(tags sync customer subscription updated)
    SyncV2->>SyncV2: executeAutumnBillingPlan
    SyncV2->>SyncV2: reconcileLicenseStateForCustomer
    SyncV2-->>Stripe: billing.updated 1 sync changes
    AutoSync-->>SubUpdated: return
    SubUpdated->>EmitCtx: emitBillingChangeWebhook
    EmitCtx-->>Stripe: billing.updated 2 ctx-tracked changes if any
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
server/src/internal/billing/v2/actions/sync/syncV2.ts:77-84
**Unconditional webhook now doubles events in `handleStripeSubscriptionUpdated`**

`syncV2` previously guarded `sendBillingUpdatedWebhook` behind an opt-in `webhook` param. The old code comment said explicitly: _"Webhook emission is opt-in via `webhook` so Stripe auto-sync callers (which already emit billing.updated from the originating action) don't double-send."_ That guard is now gone.

`handleStripeSubscriptionUpdated` calls `autoSyncUpdatedSubscription` (which runs `syncV2` → emits webhook #1 with `tags: ["sync:customer.subscription.updated"]`), then calls `emitBillingChangeWebhook` unconditionally at line 94. Whenever `expireRemovedCustomerProducts`, `syncCustomerProductStatus`, `handleStripeSubscriptionCanceled`, or `handleStripeSubscriptionRenewed` track any change into the event context, the second emission fires and customers receive two `billing.updated` events for one Stripe event. The same issue affects `autoSyncFromSubscription` (subscription.created path) which now also fires a webhook where it previously fired none.

### Issue 2 of 3
server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts:307-321
**License seat update entries carry no change detail**

The `billing.updated` plan-change entry for a license seat update always sets `previous_attributes: {}` and `item_changes: []`. Consumers can see that a customer product was `"updated"` but get no information about what changed (old/new paid seat counts). Consider populating `previous_attributes` with the previous `paid_quantity` from `originalFullCustomer`'s customer license so the event is actionable.

### Issue 3 of 3
server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts:83-95
**`continue` skips all product-level sync work when seat counts drift**

When `licenseQuantityChanges.length > 0` for a product context, the loop `continue`s before reaching the `initImmediateSyncCustomerProduct` path. `buildIncrementalSyncParams` guarantees these paths are mutually exclusive for DIFFERENT plans (product-id changed → early-return before seat check). However, both a seat drift AND a full-product re-sync can be needed for the same Autumn plan id in the version-upgrade case, since the gate in `buildIncrementalSyncParams` compares `product.id` (public ID, stable across versions) not `internal_id`. If a new product version with changed items is also accompanied by a Stripe seat-quantity change, the version upgrade would be silently skipped here.

Reviews (1): Last reviewed commit: "chore: 🤖 merge main into dev" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

@johnyeocx
johnyeocx requested a review from ay-rod as a code owner July 17, 2026 10:23
@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

@capy-ai

capy-ai Bot commented Jul 17, 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.

@vercel

vercel Bot commented Jul 17, 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 17, 2026 2:34pm
landing-page Ignored Ignored Jul 17, 2026 2:34pm

Request Review

@mcp-use

mcp-use Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deployment #708 deployment failed

StatusLogsURLURLUpdated
FailedBuild Logs | Runtime Logs🔗 ProductionN/AJul 17 2026 2:34 pm

View deployment details


Deployed on Manufact.com

Comment on lines +77 to +84
void sendBillingUpdatedWebhook({
ctx,
autumnBillingPlan,
originalFullCustomer: syncContext.fullCustomer,
tags,
});

const customerProductUpdates = (

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 Unconditional webhook now doubles events in handleStripeSubscriptionUpdated

syncV2 previously guarded sendBillingUpdatedWebhook behind an opt-in webhook param. The old code comment said explicitly: "Webhook emission is opt-in via webhook so Stripe auto-sync callers (which already emit billing.updated from the originating action) don't double-send." That guard is now gone.

handleStripeSubscriptionUpdated calls autoSyncUpdatedSubscription (which runs syncV2 → emits webhook #1 with tags: ["sync:customer.subscription.updated"]), then calls emitBillingChangeWebhook unconditionally at line 94. Whenever expireRemovedCustomerProducts, syncCustomerProductStatus, handleStripeSubscriptionCanceled, or handleStripeSubscriptionRenewed track any change into the event context, the second emission fires and customers receive two billing.updated events for one Stripe event. The same issue affects autoSyncFromSubscription (subscription.created path) which now also fires a webhook where it previously fired none.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/actions/sync/syncV2.ts
Line: 77-84

Comment:
**Unconditional webhook now doubles events in `handleStripeSubscriptionUpdated`**

`syncV2` previously guarded `sendBillingUpdatedWebhook` behind an opt-in `webhook` param. The old code comment said explicitly: _"Webhook emission is opt-in via `webhook` so Stripe auto-sync callers (which already emit billing.updated from the originating action) don't double-send."_ That guard is now gone.

`handleStripeSubscriptionUpdated` calls `autoSyncUpdatedSubscription` (which runs `syncV2` → emits webhook #1 with `tags: ["sync:customer.subscription.updated"]`), then calls `emitBillingChangeWebhook` unconditionally at line 94. Whenever `expireRemovedCustomerProducts`, `syncCustomerProductStatus`, `handleStripeSubscriptionCanceled`, or `handleStripeSubscriptionRenewed` track any change into the event context, the second emission fires and customers receive two `billing.updated` events for one Stripe event. The same issue affects `autoSyncFromSubscription` (subscription.created path) which now also fires a webhook where it previously fired none.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 307 to +321
});
}

for (const update of autumnBillingPlan.customerLicenseUpdates ?? []) {
if (update.paidQuantity === undefined || !update.customerLicenseId)
continue;
const customerProduct = licenseParentById.get(update.customerLicenseId);
if (!customerProduct) continue;

entries.push({
customerProduct,
change: {
action: "updated",
...toCustomerPlanSnapshot({ cusProduct: customerProduct }),
previous_attributes: {},

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 seat update entries carry no change detail

The billing.updated plan-change entry for a license seat update always sets previous_attributes: {} and item_changes: []. Consumers can see that a customer product was "updated" but get no information about what changed (old/new paid seat counts). Consider populating previous_attributes with the previous paid_quantity from originalFullCustomer's customer license so the event is actionable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts
Line: 307-321

Comment:
**License seat update entries carry no change detail**

The `billing.updated` plan-change entry for a license seat update always sets `previous_attributes: {}` and `item_changes: []`. Consumers can see that a customer product was `"updated"` but get no information about what changed (old/new paid seat counts). Consider populating `previous_attributes` with the previous `paid_quantity` from `originalFullCustomer`'s customer license so the event is actionable.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +83 to +95
const customerLicenseUpdates: CustomerLicenseUpdate[] = [];

for (const productContext of immediatePhase.productContexts) {
const currentCustomerProduct = productContext.currentCustomerProduct;
if (currentCustomerProduct?.product_id === productContext.fullProduct.id) {
const licenseQuantityChanges = computeCustomerLicenseQuantityChanges({
customerProduct: currentCustomerProduct,
customerLicenseQuantities: productContext.customerLicenseQuantities,
});
if (licenseQuantityChanges.length > 0) {
customerLicenseUpdates.push(
...licenseQuantityChanges.map(({ update }) => update),
);

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 continue skips all product-level sync work when seat counts drift

When licenseQuantityChanges.length > 0 for a product context, the loop continues before reaching the initImmediateSyncCustomerProduct path. buildIncrementalSyncParams guarantees these paths are mutually exclusive for DIFFERENT plans (product-id changed → early-return before seat check). However, both a seat drift AND a full-product re-sync can be needed for the same Autumn plan id in the version-upgrade case, since the gate in buildIncrementalSyncParams compares product.id (public ID, stable across versions) not internal_id. If a new product version with changed items is also accompanied by a Stripe seat-quantity change, the version upgrade would be silently skipped here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts
Line: 83-95

Comment:
**`continue` skips all product-level sync work when seat counts drift**

When `licenseQuantityChanges.length > 0` for a product context, the loop `continue`s before reaching the `initImmediateSyncCustomerProduct` path. `buildIncrementalSyncParams` guarantees these paths are mutually exclusive for DIFFERENT plans (product-id changed → early-return before seat check). However, both a seat drift AND a full-product re-sync can be needed for the same Autumn plan id in the version-upgrade case, since the gate in `buildIncrementalSyncParams` compares `product.id` (public ID, stable across versions) not `internal_id`. If a new product version with changed items is also accompanied by a Stripe seat-quantity change, the version upgrade would be silently skipped here.

How can I resolve this? If you propose a fix, please make it concise.

@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.

6 issues found and verified against the latest diff

Confidence score: 2/5

  • In server/src/internal/billing/v2/actions/sync/syncV2.ts and server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts, license replacement/mixed sync paths can skip or miss customerLicenseUpdates, which can leave stale assignments or drop custom license/item changes after sync — add reconciliation for replacement flows and remove the continue bypass so custom state is always initialized/collected before merging.
  • shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts appears to remove a guard that filtered plan products, but entity-scoped rows may include customer_license_link_id; merging as-is risks misclassifying assigned license products as normal subscriptions/purchases in API output — restore the existing plan-product filter before spreading custom fields.
  • vite/src/views/products/plan/components/SaveChangesBar.tsx no longer invalidates license_products on save, so UI selectors/tables can show stale license-linking state even when backend changes succeeded — keep invalidateLicenseProducts() in the success invalidation list before merge.
  • server/src/internal/products/repos/utils/composeFullProductQuery.ts and shared/api/products/crud/licenses/planLicenseParams.ts introduce lower-severity API correctness issues (leaking internal entitlementRefs/priceRefs and missing preview diffs for customize-only edits), which can confuse clients and expose internal shape — strip those relation arrays during normalization and include customization change signals in preview responses.
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="shared/api/products/crud/licenses/planLicenseParams.ts">

<violation number="1" location="shared/api/products/crud/licenses/planLicenseParams.ts:10">
P2: Plan previews report no change when only `customize` is edited, even though the update applies the customization. Including the effective customization (or a customization-change marker) in `previewPlanLicenseSync` comparison and output would keep previews aligned with the newly accepted input.</violation>
</file>

<file name="server/src/internal/products/repos/utils/composeFullProductQuery.ts">

<violation number="1" location="server/src/internal/products/repos/utils/composeFullProductQuery.ts:92">
P2: Normalized license responses now leak the internal `entitlementRefs` and `priceRefs` relation arrays because `normalizeLinkProduct` spreads the entire hydrated link. Destructuring these relations before normalization would keep the returned object aligned with `FullPlanLicense` and avoid exposing database junction rows.</violation>
</file>

<file name="shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts">

<violation number="1" location="shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts:11">
P1: The entity API now returns assigned license products as normal subscriptions or purchases. Entity-scoped rows can include `customer_license_link_id`, so retain the existing plan-product filter before spreading `customer_products` to keep assigned license products hidden from the API.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/sync/syncV2.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/sync/syncV2.ts:54">
P1: Licensed syncs that replace the current product do not reconcile license state: those plans have inserted/expired customer products but no `customerLicenseUpdates`, so this condition is false. The old license assignments can remain attached to the expired parent and produce stale seat access; the trigger should cover all sync mutations that add or expire license-bearing customer products, not only quantity updates.</violation>
</file>

<file name="server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts">

<violation number="1" location="server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts:96">
P1: A mixed sync can silently lose custom license/item changes when any existing license quantity also changes, because this `continue` bypasses product initialization and collection of `customPrices`, `customEntitlements`, and `insertPlanLicenses`. The optimization should only short-circuit a truly quantity-only context; otherwise preserve the normal initialization path while applying the in-place quantity updates.</violation>
</file>

<file name="vite/src/views/products/plan/components/SaveChangesBar.tsx">

<violation number="1" location="vite/src/views/products/plan/components/SaveChangesBar.tsx:149">
P2: License linking changes are not propagated to the `license_products` query after this save. Retaining `invalidateLicenseProducts()` in this success invalidation list would keep license selectors and license-product tables consistent with the saved `licenses` array.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

}): FullCusProduct[] =>
fullSubject.subjectType === "entity"
? [
...fullSubject.customer_products,

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: The entity API now returns assigned license products as normal subscriptions or purchases. Entity-scoped rows can include customer_license_link_id, so retain the existing plan-product filter before spreading customer_products to keep assigned license products hidden from the API.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts, line 11:

<comment>The entity API now returns assigned license products as normal subscriptions or purchases. Entity-scoped rows can include `customer_license_link_id`, so retain the existing plan-product filter before spreading `customer_products` to keep assigned license products hidden from the API.</comment>

<file context>
@@ -1,9 +1,18 @@
+}): FullCusProduct[] =>
+	fullSubject.subjectType === "entity"
+		? [
+				...fullSubject.customer_products,
+				// ...(fullSubject.aggregated_customer_products ?? []),
+			]
</file context>
Suggested change
...fullSubject.customer_products,
...fullSubject.customer_products.filter(
(customerProduct) =>
customerProduct.customer_license_link_id == null ||
customerProduct.internal_entity_id == null,
),


// 4. Execute
await executeAutumnBillingPlan({ ctx, autumnBillingPlan });
if ((autumnBillingPlan.customerLicenseUpdates?.length ?? 0) > 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.

P1: Licensed syncs that replace the current product do not reconcile license state: those plans have inserted/expired customer products but no customerLicenseUpdates, so this condition is false. The old license assignments can remain attached to the expired parent and produce stale seat access; the trigger should cover all sync mutations that add or expire license-bearing customer products, not only quantity updates.

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/sync/syncV2.ts, line 54:

<comment>Licensed syncs that replace the current product do not reconcile license state: those plans have inserted/expired customer products but no `customerLicenseUpdates`, so this condition is false. The old license assignments can remain attached to the expired parent and produce stale seat access; the trigger should cover all sync mutations that add or expire license-bearing customer products, not only quantity updates.</comment>

<file context>
@@ -63,6 +51,13 @@ export const syncV2 = async ({
 
 	// 4. Execute
 	await executeAutumnBillingPlan({ ctx, autumnBillingPlan });
+	if ((autumnBillingPlan.customerLicenseUpdates?.length ?? 0) > 0) {
+		await reconcileLicenseStateForCustomer({
+			ctx,
</file context>

customerLicenseUpdates.push(
...licenseQuantityChanges.map(({ update }) => update),
);
continue;

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 mixed sync can silently lose custom license/item changes when any existing license quantity also changes, because this continue bypasses product initialization and collection of customPrices, customEntitlements, and insertPlanLicenses. The optimization should only short-circuit a truly quantity-only context; otherwise preserve the normal initialization path while applying the in-place quantity updates.

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/sync/compute/computeSyncImmediatePhase.ts, line 96:

<comment>A mixed sync can silently lose custom license/item changes when any existing license quantity also changes, because this `continue` bypasses product initialization and collection of `customPrices`, `customEntitlements`, and `insertPlanLicenses`. The optimization should only short-circuit a truly quantity-only context; otherwise preserve the normal initialization path while applying the in-place quantity updates.</comment>

<file context>
@@ -76,14 +80,29 @@ export const computeSyncImmediatePhase = ({
+				customerLicenseUpdates.push(
+					...licenseQuantityChanges.map(({ update }) => update),
+				);
+				continue;
+			}
+		}
</file context>

Comment thread server/src/internal/licenses/actions/links/syncPlanLicenses.ts
onSuccess: () =>
Promise.all([
invalidatePlanLicenses(),
invalidateProduct(),

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 linking changes are not propagated to the license_products query after this save. Retaining invalidateLicenseProducts() in this success invalidation list would keep license selectors and license-product tables consistent with the saved licenses array.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At vite/src/views/products/plan/components/SaveChangesBar.tsx, line 149:

<comment>License linking changes are not propagated to the `license_products` query after this save. Retaining `invalidateLicenseProducts()` in this success invalidation list would keep license selectors and license-product tables consistent with the saved `licenses` array.</comment>

<file context>
@@ -131,36 +125,32 @@ export const SaveChangesBar = ({
+					onSuccess: () =>
+						Promise.all([
+							invalidatePlanLicenses(),
+							invalidateProduct(),
+							invalidateProducts(),
+						]),
</file context>

license_plan_id: z.string(),
included: z.number().int().min(0).optional(),
prepaid_only: z.boolean().optional(),
customize: LicenseCustomizeSchema.nullish(),

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: Plan previews report no change when only customize is edited, even though the update applies the customization. Including the effective customization (or a customization-change marker) in previewPlanLicenseSync comparison and output would keep previews aligned with the newly accepted input.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/api/products/crud/licenses/planLicenseParams.ts, line 10:

<comment>Plan previews report no change when only `customize` is edited, even though the update applies the customization. Including the effective customization (or a customization-change marker) in `previewPlanLicenseSync` comparison and output would keep previews aligned with the newly accepted input.</comment>

<file context>
@@ -1,12 +1,13 @@
 	license_plan_id: z.string(),
 	included: z.number().int().min(0).optional(),
 	prepaid_only: z.boolean().optional(),
+	customize: LicenseCustomizeSchema.nullish(),
 	metadata: z.record(z.string(), z.unknown()).optional(),
 	version: z.number().int().min(1).optional().meta({
</file context>

Comment on lines +92 to +109
const normalizeLicenseProduct = (
link: NonNullable<ProductWithLicenseRelations["licenses"]>[number],
) => {
const baseLink = normalizeLinkProduct(link, link.product);
return {
...normalizeLinkProduct(
link,
link.customized
? {
...link.product,
prices: link.priceRefs.map(({ price }) => price),
entitlements: link.entitlementRefs.map(
({ entitlement }) => entitlement,
),
}
: link.product,
),
...(link.customized ? { base_product: baseLink.product } : {}),

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: Normalized license responses now leak the internal entitlementRefs and priceRefs relation arrays because normalizeLinkProduct spreads the entire hydrated link. Destructuring these relations before normalization would keep the returned object aligned with FullPlanLicense and avoid exposing database junction rows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/products/repos/utils/composeFullProductQuery.ts, line 92:

<comment>Normalized license responses now leak the internal `entitlementRefs` and `priceRefs` relation arrays because `normalizeLinkProduct` spreads the entire hydrated link. Destructuring these relations before normalization would keep the returned object aligned with `FullPlanLicense` and avoid exposing database junction rows.</comment>

<file context>
@@ -67,6 +89,27 @@ const normalizeLinkProduct = <T extends DbPlanLicense>(
 	},
 });
 
+const normalizeLicenseProduct = (
+	link: NonNullable<ProductWithLicenseRelations["licenses"]>[number],
+) => {
</file context>
Suggested change
const normalizeLicenseProduct = (
link: NonNullable<ProductWithLicenseRelations["licenses"]>[number],
) => {
const baseLink = normalizeLinkProduct(link, link.product);
return {
...normalizeLinkProduct(
link,
link.customized
? {
...link.product,
prices: link.priceRefs.map(({ price }) => price),
entitlements: link.entitlementRefs.map(
({ entitlement }) => entitlement,
),
}
: link.product,
),
...(link.customized ? { base_product: baseLink.product } : {}),
const normalizeLicenseProduct = (
link: NonNullable<ProductWithLicenseRelations["licenses"]>[number],
) => {
const { entitlementRefs, priceRefs, ...dbLink } = link;
const baseLink = normalizeLinkProduct(dbLink, link.product);
return {
...normalizeLinkProduct(
dbLink,
link.customized
? {
...link.product,
prices: priceRefs.map(({ price }) => price),
entitlements: entitlementRefs.map(
({ entitlement }) => entitlement,
),
}
: link.product,
),
...(link.customized ? { base_product: baseLink.product } : {}),
};
};

@socket-security

socket-security Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​mintlify@​4.2.632 ⏵ 4.2.69455 +11009410090
Updatednpm/​posthog-js@​1.393.0 ⏵ 1.399.556 -2710072 -8100100
Updatednpm/​react-router-dom@​7.18.0 ⏵ 7.18.11001006596 -1100
Updatednpm/​braintrust@​3.20.0 ⏵ 3.21.06610010097 +280 -19
Updatednpm/​@​tanstack/​react-virtual@​3.14.3 ⏵ 3.14.61001006997 +3100
Updatednpm/​@​ai-sdk/​anthropic@​3.0.85 ⏵ 4.0.1270 -91008898100
Updatednpm/​@​anthropic-ai/​claude-agent-sdk@​0.3.187 ⏵ 0.3.2097310092 +110070
Updatednpm/​@​vercel/​sandbox@​2.2.1 ⏵ 2.7.072 +110010099 +2100
Updatednpm/​typescript-eslint@​8.62.0 ⏵ 8.64.01001007498100
Updatednpm/​@​chat-adapter/​web@​4.31.0 ⏵ 4.34.07410010095 +2100
Updatednpm/​@​chat-adapter/​state-pg@​4.31.0 ⏵ 4.34.07410010097 +1100
Updatednpm/​@​ai-sdk/​react@​3.0.210 ⏵ 3.0.22795 -410074 +198100
Updatednpm/​@​opentelemetry/​sdk-trace-base@​2.8.0 ⏵ 2.9.075 -310010094 -1100
Updatednpm/​ai@​6.0.205 ⏵ 6.0.22975 -2310010099100
Updatednpm/​@​ai-sdk/​provider@​4.0.2 ⏵ 3.0.14100 +11007598 +1100
Updatednpm/​@​chat-adapter/​slack@​4.31.0 ⏵ 4.34.077 +110010098 +2100
Updatednpm/​chat@​4.31.0 ⏵ 4.34.07910010098 +2100
Updatednpm/​posthog-node@​5.38.2 ⏵ 5.41.091 -410079 +1100100
Updatednpm/​nanoid@​5.1.15 ⏵ 5.1.1610010079 +195100
Updatednpm/​@​ai-sdk/​anthropic@​3.0.85 ⏵ 3.0.9679 +110088 +198100
Updatednpm/​react-router@​7.18.0 ⏵ 7.18.1941007997100
Updatednpm/​recharts@​3.9.0 ⏵ 3.9.279 +610010095100
Updatednpm/​@​types/​node@​24.13.2 ⏵ 22.20.11001008195100
Updatednpm/​radix-ui@​1.6.0 ⏵ 1.6.2971008198 +2100
Updatednpm/​@​types/​node@​24.13.2 ⏵ 25.9.51001008196 +2100
Updatednpm/​@​types/​node@​24.13.2 ⏵ 24.13.3100 +110081 +195100
Updatednpm/​@​axiomhq/​pino@​1.7.0 ⏵ 1.8.0100 +110081 +295 +2100
Updatednpm/​tsx@​4.22.4 ⏵ 4.23.1100 +110082 +194 +1100
Updatednpm/​just-bash@​3.0.2 ⏵ 3.1.08310099 +194 +1100
Updatednpm/​@​tanstack/​react-form@​1.33.0 ⏵ 1.33.2100 +110084 +198 +3100
Updatednpm/​tailwindcss@​4.3.1 ⏵ 4.3.2100 +110084 +198100
Updatednpm/​@​axiomhq/​js@​1.7.0 ⏵ 1.8.0100 +110084 +295 +3100
See 38 more rows in the dashboard

View full report

@socket-security

socket-security Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm @aws-sdk/client-ec2 is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/artillery@2.0.33npm/@aws-sdk/client-ec2@3.1085.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@aws-sdk/client-ec2@3.1085.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @next/mdx is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/@next/mdx@16.2.10

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@next/mdx@16.2.10. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm just-bash is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/leaf/package.jsonnpm/just-bash@3.1.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/just-bash@3.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm mermaid is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/streamdown@1.6.11npm/mermaid@11.16.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/mermaid@11.16.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: packages/autumn-js/package.jsonnpm/react-email@4.0.16npm/next@15.5.20

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@15.5.20. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm webpack is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?npm/xo@0.53.1npm/webpack@5.108.4

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/webpack@5.108.4. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@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.

No issues found across 88 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

og2701 and others added 2 commits July 17, 2026 12:35
charlietlamb and others added 3 commits July 17, 2026 13:36
Add license-aware plan update previews, preserve customer definitions
across versioned and in-place edits, and surface the resulting catalog
changes in the dashboard.
feat(licenses): support catalog license plan updates
fix(sync): preserve matched plan version
@vercel
vercel Bot temporarily deployed to staging – autumn-vite July 17, 2026 14:34 Inactive
@charlietlamb
charlietlamb merged commit 73088e8 into main Jul 17, 2026
20 of 22 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.

3 participants