Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const computeAttachPlan = ({
customerLicenseBillingContext:
attachBillingContext.customerLicenseBillingContext,
carryCustomerLicenseState: planTiming === "immediate",
carryOverUsages: params.carry_over_usages,
})
: [];
const customerLicenseTransitions =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const batchTransition = async ({

const hasEntitlementPriceTransitions =
entitlementPriceTransitions.transitions.length > 0 ||
entitlementPriceTransitions.retained.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.

P1: Retained-only product changes are still discarded before batchTransition runs, so their usage is not reset. Include retained in the upstream no-op/transition detection as well.

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/batchTransition.ts, line 46:

<comment>Retained-only product changes are still discarded before `batchTransition` runs, so their usage is not reset. Include `retained` in the upstream no-op/transition detection as well.</comment>

<file context>
@@ -43,6 +43,7 @@ export const batchTransition = async ({
 
 	const hasEntitlementPriceTransitions =
 		entitlementPriceTransitions.transitions.length > 0 ||
+		entitlementPriceTransitions.retained.length > 0 ||
 		entitlementPriceTransitions.added.length > 0 ||
 		entitlementPriceTransitions.deleted.length > 0;
</file context>

entitlementPriceTransitions.added.length > 0 ||
entitlementPriceTransitions.deleted.length > 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CarryOverUsages,
EntitlementWithFeature,
InitCustomerEntitlementContext,
InitFullCustomerProductOptions,
Expand All @@ -20,12 +21,14 @@ export const computeBatchTransitionOperations = ({
productTransitions,
customerEntitlementInitContext,
customerEntitlementInitOptions,
carryOverUsages,
}: {
candidateOutgoingEntitlements: EntitlementWithFeature[];
candidateOutgoingBasePrices: Price[];
productTransitions: ProductTransitions;
customerEntitlementInitContext: InitCustomerEntitlementContext;
customerEntitlementInitOptions: InitFullCustomerProductOptions;
carryOverUsages?: CarryOverUsages;
}): Pick<
CustomerEntitlementBatchTransition,
"operations" | "unhandledTransitions"
Expand All @@ -36,6 +39,7 @@ export const computeBatchTransitionOperations = ({
entitlementPriceTransitions: productTransitions.entitlementPrices,
customerEntitlementInitContext,
customerEntitlementInitOptions,
carryOverUsages,
});
const basePriceOperation = computeBasePriceOperation({
basePriceTransition: productTransitions.basePrice,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
type CarryOverUsages,
type EntitlementWithFeature,
featureUtils,
getStartingBalance,
isBooleanEntitlement,
isUnlimitedEntitlement,
Expand Down Expand Up @@ -32,14 +34,35 @@ export const computeCustomerEntitlementInitialState = ({
};
};

/** Mirrors attach: allocated usage always carries, `carry_from_previous`
* carries its own entitlement, and the param carries listed consumables. */
export const shouldCarryOverUsage = ({
toEntitlement,
carryOverUsages,
}: {
toEntitlement: EntitlementWithFeature;
carryOverUsages: CarryOverUsages;
}): boolean => {
if (featureUtils.isAllocated(toEntitlement.feature)) return true;
if (toEntitlement.carry_from_previous) return true;
if (!carryOverUsages?.enabled) return false;
if (!carryOverUsages.feature_ids) return true;
return carryOverUsages.feature_ids.includes(toEntitlement.feature.id);
};

const computeBalancePatch = ({
fromInitialState,
toInitialState,
carryUsage,
}: {
fromInitialState: CustomerEntitlementInitialState;
toInitialState: CustomerEntitlementInitialState;
carryUsage: boolean;
}): CustomerEntitlementBalancePatch | undefined => {
if (fromInitialState.tracksBalance && toInitialState.tracksBalance) {
if (!carryUsage) {
return { type: "set", amount: toInitialState.granted };
}
const amount = new Decimal(toInitialState.granted).sub(
fromInitialState.granted,
);
Expand All @@ -57,9 +80,11 @@ const computeBalancePatch = ({
export const computeCustomerEntitlementPatch = ({
fromEntitlement,
toEntitlement,
carryOverUsages,
}: {
fromEntitlement: EntitlementWithFeature;
toEntitlement: EntitlementWithFeature;
carryOverUsages?: CarryOverUsages;
}): CustomerEntitlementPatch => {
if (
isBooleanEntitlement({ entitlement: fromEntitlement }) ||
Expand All @@ -75,7 +100,11 @@ export const computeCustomerEntitlementPatch = ({
entitlement: toEntitlement,
});
const patch: CustomerEntitlementPatch = {};
const balance = computeBalancePatch({ fromInitialState, toInitialState });
const balance = computeBalancePatch({
fromInitialState,
toInitialState,
carryUsage: shouldCarryOverUsage({ toEntitlement, carryOverUsages }),

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: When the migration groupFilterReplaceRows path updates a changed entitlement definition, this call resets each live balance to the new grant because it omits carryOverUsages. Preserve the migration contract by passing an explicit enabled carry-over config there, or otherwise keep migration callers on delta semantics while applying the reset default only to license transitions.

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/compute/operations/entitlementPriceOperations/computeCustomerEntitlementPatch.ts, line 106:

<comment>When the migration `groupFilterReplaceRows` path updates a changed entitlement definition, this call resets each live balance to the new grant because it omits `carryOverUsages`. Preserve the migration contract by passing an explicit enabled carry-over config there, or otherwise keep migration callers on delta semantics while applying the reset default only to license transitions.</comment>

<file context>
@@ -75,7 +100,11 @@ export const computeCustomerEntitlementPatch = ({
+	const balance = computeBalancePatch({
+		fromInitialState,
+		toInitialState,
+		carryUsage: shouldCarryOverUsage({ toEntitlement, carryOverUsages }),
+	});
 
</file context>

});

if (balance) patch.balance = balance;
if (fromInitialState.unlimited !== toInitialState.unlimited) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
type CarryOverUsages,
EntInterval,
type EntitlementPrice,
type EntitlementWithFeature,
entToPooledBalanceIdentity,
entsAreSame,
entsHaveSamePooledIdentity,
entToPooledBalanceIdentity,
type InitCustomerEntitlementContext,
type InitFullCustomerProductOptions,
isBooleanEntitlement,
PooledBalanceResetMode,
} from "@autumn/shared";
import { initCustomerEntitlementFields } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields";
Expand All @@ -23,6 +25,7 @@ import type {
import {
computeCustomerEntitlementInitialState,
computeCustomerEntitlementPatch,
shouldCarryOverUsage,
} from "./computeCustomerEntitlementPatch";

const findCandidateEntitlementIds = ({
Expand All @@ -47,9 +50,11 @@ const findCandidateEntitlementIds = ({
const computeReplaceOperation = ({
candidateOutgoingEntitlements,
transition,
carryOverUsages,
}: {
candidateOutgoingEntitlements: EntitlementWithFeature[];
transition: EntitlementPriceTransition;
carryOverUsages?: CarryOverUsages;
}): ReplaceEntitlementPriceOperation | undefined => {
const { fromEntitlementPrice, toEntitlementPrice } = transition;
const fromEntitlement = fromEntitlementPrice.entitlement;
Expand All @@ -62,15 +67,19 @@ const computeReplaceOperation = ({
(entitlementId) =>
!definitionsAreSame || entitlementId !== toEntitlement.id,
);
if (fromEntitlementIds.length === 0) return undefined;
if (fromEntitlementIds.length === 0) {
if (!definitionsAreSame) return undefined;
fromEntitlementIds.push(toEntitlement.id);
}

const fromIsPooled = fromEntitlement.pooled === true;
const toIsPooled = toEntitlement.pooled === true;
const isPooledReplace = fromIsPooled && toIsPooled;
const customerEntitlementPatch = computeCustomerEntitlementPatch({
fromEntitlement,
toEntitlement,
carryOverUsages,
});
const fromIsPooled = fromEntitlement.pooled === true;
const toIsPooled = toEntitlement.pooled === true;
const isPooledReplace = fromIsPooled && toIsPooled;
if (!isPooledReplace) {
return {
type: "replace",
Expand All @@ -82,10 +91,15 @@ const computeReplaceOperation = ({
};
}

const incrementAmount =
const pooledContributionPatch =

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: When a pooled transition carries usage and the incoming grant equals the outgoing grant, customerEntitlementPatch.balance is undefined, but this fallback resets both contribution cycles to the raw grant. Preserve the undefined patch instead of overwriting carried pooled contribution state.

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/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts, line 94:

<comment>When a pooled transition carries usage and the incoming grant equals the outgoing grant, `customerEntitlementPatch.balance` is undefined, but this fallback resets both contribution cycles to the raw grant. Preserve the undefined patch instead of overwriting carried pooled contribution state.</comment>

<file context>
@@ -82,10 +91,15 @@ const computeReplaceOperation = ({
 	}
 
-	const incrementAmount =
+	const pooledContributionPatch =
 		customerEntitlementPatch.balance?.type === "increment"
-			? customerEntitlementPatch.balance.amount
</file context>

customerEntitlementPatch.balance?.type === "increment"
? customerEntitlementPatch.balance.amount
: 0;
? customerEntitlementPatch.balance
: {
type: "set" as const,
amount: computeCustomerEntitlementInitialState({
entitlement: toEntitlement,
}).granted,
};

return {
type: "replace",
Expand All @@ -96,7 +110,7 @@ const computeReplaceOperation = ({
customerEntitlementPatch: {
unlimited: customerEntitlementPatch.unlimited,
},
pooledContributionPatch: { type: "increment", amount: incrementAmount },
pooledContributionPatch,
};
};

Expand Down Expand Up @@ -196,11 +210,13 @@ const computeTransitionOperations = ({
transition,
initContext,
initOptions,
carryOverUsages,
}: {
candidateOutgoingEntitlements: EntitlementWithFeature[];
transition: EntitlementPriceTransition;
initContext: InitCustomerEntitlementContext;
initOptions: InitFullCustomerProductOptions;
carryOverUsages?: CarryOverUsages;
}): EntitlementPriceOperation[] => {
const fromEntitlement = transition.fromEntitlementPrice.entitlement;
const toEntitlement = transition.toEntitlementPrice.entitlement;
Expand All @@ -215,6 +231,7 @@ const computeTransitionOperations = ({
const operation = computeReplaceOperation({
candidateOutgoingEntitlements,
transition,
carryOverUsages,
});
return operation ? [operation] : [];
}
Expand Down Expand Up @@ -244,18 +261,21 @@ export const computeEntitlementPriceOperations = ({
entitlementPriceTransitions,
customerEntitlementInitContext,
customerEntitlementInitOptions,
carryOverUsages,
}: {
candidateOutgoingEntitlements: EntitlementWithFeature[];
entitlementPriceTransitions: ComputedEntitlementPriceTransitions;
customerEntitlementInitContext: InitCustomerEntitlementContext;
customerEntitlementInitOptions: InitFullCustomerProductOptions;
carryOverUsages?: CarryOverUsages;
}): {
operations: EntitlementPriceOperation[];
unhandled: ComputedEntitlementPriceTransitions;
} => {
const operations: EntitlementPriceOperation[] = [];
const unhandled: ComputedEntitlementPriceTransitions = {
transitions: [],
retained: [],
added: [],
deleted: [],
};
Expand All @@ -275,10 +295,44 @@ export const computeEntitlementPriceOperations = ({
transition,
initContext: customerEntitlementInitContext,
initOptions: customerEntitlementInitOptions,
carryOverUsages,
}),
);
}

for (const transition of entitlementPriceTransitions.retained) {
if (
isBooleanEntitlement({
entitlement: transition.toEntitlementPrice.entitlement,
})
) {
continue;
}
if (
hasPrice(transition.fromEntitlementPrice) ||
hasPrice(transition.toEntitlementPrice)
) {
unhandled.retained.push(transition);
continue;
}

if (
shouldCarryOverUsage({
toEntitlement: transition.toEntitlementPrice.entitlement,
carryOverUsages,
})
) {
continue;
}

const operation = computeReplaceOperation({

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: When a license has multiple IDs for the same retained definition, this reset omits rows already using toEntitlement.id. Include the target ID in retained reset operations instead of adding it only when no other candidate exists.

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/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts, line 328:

<comment>When a license has multiple IDs for the same retained definition, this reset omits rows already using `toEntitlement.id`. Include the target ID in retained reset operations instead of adding it only when no other candidate exists.</comment>

<file context>
@@ -275,10 +295,44 @@ export const computeEntitlementPriceOperations = ({
+			continue;
+		}
+
+		const operation = computeReplaceOperation({
+			candidateOutgoingEntitlements,
+			transition,
</file context>

candidateOutgoingEntitlements,
transition,
carryOverUsages,
});
if (operation) operations.push(operation);
}

for (const entitlementPrice of entitlementPriceTransitions.added) {
if (hasPrice(entitlementPrice)) {
unhandled.added.push(entitlementPrice);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type EntitlementPriceTransition = {

export type ComputedEntitlementPriceTransitions = {
transitions: EntitlementPriceTransition[];
/** Same-definition survivors. They still need a replace when usage resets. */
retained: EntitlementPriceTransition[];
added: EntitlementPrice[];
deleted: EntitlementPrice[];
};
Expand Down Expand Up @@ -53,6 +55,7 @@ export const computeEntitlementPriceTransitions = ({
}

const transitions: EntitlementPriceTransition[] = [];
const retained: EntitlementPriceTransition[] = [];
const deleted: EntitlementPrice[] = [];
fromEntitlementPrices.forEach((fromEntitlementPrice, fromIndex) => {
const toEntitlementPrice = matchedByFromIndex.get(fromIndex);
Expand All @@ -73,13 +76,16 @@ export const computeEntitlementPriceTransitions = ({
})
) {
transitions.push({ fromEntitlementPrice, toEntitlementPrice });
return;
}

retained.push({ fromEntitlementPrice, toEntitlementPrice });

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: Same-definition survivors now land in retained instead of transitions, but the migration consumers weren't updated to read it. computeBatchMigrationOperations.ts (replaceEntitlements, toLicenseOps) and checkUpdatePlanTransitionEligibility.ts only iterate link.transitions.transitions/.added/.deleted, so version-bump and customize paths will silently stop emitting replace ops for unchanged entitlements. Fold retained into those consumers (with the carry-over gating) so the migration lane matches the batch-transition lane, or this drops the reset/repoint behavior they relied on.

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/compute/transitions/computeEntitlementPriceTransitions.ts, line 82:

<comment>Same-definition survivors now land in `retained` instead of `transitions`, but the migration consumers weren't updated to read it. `computeBatchMigrationOperations.ts` (replaceEntitlements, toLicenseOps) and `checkUpdatePlanTransitionEligibility.ts` only iterate `link.transitions.transitions`/`.added`/`.deleted`, so version-bump and customize paths will silently stop emitting replace ops for unchanged entitlements. Fold `retained` into those consumers (with the carry-over gating) so the migration lane matches the batch-transition lane, or this drops the reset/repoint behavior they relied on.</comment>

<file context>
@@ -73,13 +76,16 @@ export const computeEntitlementPriceTransitions = ({
+			return;
 		}
+
+		retained.push({ fromEntitlementPrice, toEntitlementPrice });
 	});
 
</file context>

});

const added = toEntitlementPrices.filter(
(toEntitlementPrice) =>
!claimedToEntitlementIds.has(toEntitlementPrice.entitlement.id),
);

return { transitions, added, deleted };
return { transitions, retained, added, deleted };
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ const executeReplacement = async ({
operation: ReplaceEntitlementPriceOperation;
}) => {
if (operation.fromEntitlementIds.length === 0) return 0;
if (operation.fromEntitlementIds.includes(operation.toEntitlementId)) {
throw new Error(
"Batch replacement requires different outgoing and incoming entitlement IDs",
);
}

return executeBatchedMutation({
db: ctx.db,
Expand Down
Loading
Loading