Skip to content

Commit 2f9ca97

Browse files
fix(licenses): reset transition usage unless carry-over is enabled
Run smaller license transitions synchronously and preserve explicit carry-over semantics across retained and pooled entitlements. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7a103db commit 2f9ca97

31 files changed

Lines changed: 1921 additions & 159 deletions

File tree

server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const computeAttachPlan = ({
7373
customerLicenseBillingContext:
7474
attachBillingContext.customerLicenseBillingContext,
7575
carryCustomerLicenseState: planTiming === "immediate",
76+
carryOverUsages: params.carry_over_usages,
7677
})
7778
: [];
7879
const customerLicenseTransitions =

server/src/internal/billing/v2/actions/batchTransition/batchTransition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const batchTransition = async ({
4343

4444
const hasEntitlementPriceTransitions =
4545
entitlementPriceTransitions.transitions.length > 0 ||
46+
entitlementPriceTransitions.retained.length > 0 ||
4647
entitlementPriceTransitions.added.length > 0 ||
4748
entitlementPriceTransitions.deleted.length > 0;
4849

server/src/internal/billing/v2/actions/batchTransition/compute/operations/computeBatchTransitionOperations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
CarryOverUsages,
23
EntitlementWithFeature,
34
InitCustomerEntitlementContext,
45
InitFullCustomerProductOptions,
@@ -20,12 +21,14 @@ export const computeBatchTransitionOperations = ({
2021
productTransitions,
2122
customerEntitlementInitContext,
2223
customerEntitlementInitOptions,
24+
carryOverUsages,
2325
}: {
2426
candidateOutgoingEntitlements: EntitlementWithFeature[];
2527
candidateOutgoingBasePrices: Price[];
2628
productTransitions: ProductTransitions;
2729
customerEntitlementInitContext: InitCustomerEntitlementContext;
2830
customerEntitlementInitOptions: InitFullCustomerProductOptions;
31+
carryOverUsages?: CarryOverUsages;
2932
}): Pick<
3033
CustomerEntitlementBatchTransition,
3134
"operations" | "unhandledTransitions"
@@ -36,6 +39,7 @@ export const computeBatchTransitionOperations = ({
3639
entitlementPriceTransitions: productTransitions.entitlementPrices,
3740
customerEntitlementInitContext,
3841
customerEntitlementInitOptions,
42+
carryOverUsages,
3943
});
4044
const basePriceOperation = computeBasePriceOperation({
4145
basePriceTransition: productTransitions.basePrice,

server/src/internal/billing/v2/actions/batchTransition/compute/operations/entitlementPriceOperations/computeCustomerEntitlementPatch.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
2+
type CarryOverUsages,
23
type EntitlementWithFeature,
4+
featureUtils,
35
getStartingBalance,
46
isBooleanEntitlement,
57
isUnlimitedEntitlement,
@@ -32,14 +34,35 @@ export const computeCustomerEntitlementInitialState = ({
3234
};
3335
};
3436

37+
/** Mirrors attach: allocated usage always carries, `carry_from_previous`
38+
* carries its own entitlement, and the param carries listed consumables. */
39+
export const shouldCarryOverUsage = ({
40+
toEntitlement,
41+
carryOverUsages,
42+
}: {
43+
toEntitlement: EntitlementWithFeature;
44+
carryOverUsages: CarryOverUsages;
45+
}): boolean => {
46+
if (featureUtils.isAllocated(toEntitlement.feature)) return true;
47+
if (toEntitlement.carry_from_previous) return true;
48+
if (!carryOverUsages?.enabled) return false;
49+
if (!carryOverUsages.feature_ids) return true;
50+
return carryOverUsages.feature_ids.includes(toEntitlement.feature.id);
51+
};
52+
3553
const computeBalancePatch = ({
3654
fromInitialState,
3755
toInitialState,
56+
carryUsage,
3857
}: {
3958
fromInitialState: CustomerEntitlementInitialState;
4059
toInitialState: CustomerEntitlementInitialState;
60+
carryUsage: boolean;
4161
}): CustomerEntitlementBalancePatch | undefined => {
4262
if (fromInitialState.tracksBalance && toInitialState.tracksBalance) {
63+
if (!carryUsage) {
64+
return { type: "set", amount: toInitialState.granted };
65+
}
4366
const amount = new Decimal(toInitialState.granted).sub(
4467
fromInitialState.granted,
4568
);
@@ -57,9 +80,11 @@ const computeBalancePatch = ({
5780
export const computeCustomerEntitlementPatch = ({
5881
fromEntitlement,
5982
toEntitlement,
83+
carryOverUsages,
6084
}: {
6185
fromEntitlement: EntitlementWithFeature;
6286
toEntitlement: EntitlementWithFeature;
87+
carryOverUsages?: CarryOverUsages;
6388
}): CustomerEntitlementPatch => {
6489
if (
6590
isBooleanEntitlement({ entitlement: fromEntitlement }) ||
@@ -75,7 +100,11 @@ export const computeCustomerEntitlementPatch = ({
75100
entitlement: toEntitlement,
76101
});
77102
const patch: CustomerEntitlementPatch = {};
78-
const balance = computeBalancePatch({ fromInitialState, toInitialState });
103+
const balance = computeBalancePatch({
104+
fromInitialState,
105+
toInitialState,
106+
carryUsage: shouldCarryOverUsage({ toEntitlement, carryOverUsages }),
107+
});
79108

80109
if (balance) patch.balance = balance;
81110
if (fromInitialState.unlimited !== toInitialState.unlimited) {

server/src/internal/billing/v2/actions/batchTransition/compute/operations/entitlementPriceOperations/computeEntitlementPriceOperations.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
2+
type CarryOverUsages,
23
EntInterval,
34
type EntitlementPrice,
45
type EntitlementWithFeature,
5-
entToPooledBalanceIdentity,
66
entsAreSame,
77
entsHaveSamePooledIdentity,
8+
entToPooledBalanceIdentity,
89
type InitCustomerEntitlementContext,
910
type InitFullCustomerProductOptions,
11+
isBooleanEntitlement,
1012
PooledBalanceResetMode,
1113
} from "@autumn/shared";
1214
import { initCustomerEntitlementFields } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlementFields";
@@ -23,6 +25,7 @@ import type {
2325
import {
2426
computeCustomerEntitlementInitialState,
2527
computeCustomerEntitlementPatch,
28+
shouldCarryOverUsage,
2629
} from "./computeCustomerEntitlementPatch";
2730

2831
const findCandidateEntitlementIds = ({
@@ -47,9 +50,11 @@ const findCandidateEntitlementIds = ({
4750
const computeReplaceOperation = ({
4851
candidateOutgoingEntitlements,
4952
transition,
53+
carryOverUsages,
5054
}: {
5155
candidateOutgoingEntitlements: EntitlementWithFeature[];
5256
transition: EntitlementPriceTransition;
57+
carryOverUsages?: CarryOverUsages;
5358
}): ReplaceEntitlementPriceOperation | undefined => {
5459
const { fromEntitlementPrice, toEntitlementPrice } = transition;
5560
const fromEntitlement = fromEntitlementPrice.entitlement;
@@ -62,15 +67,19 @@ const computeReplaceOperation = ({
6267
(entitlementId) =>
6368
!definitionsAreSame || entitlementId !== toEntitlement.id,
6469
);
65-
if (fromEntitlementIds.length === 0) return undefined;
70+
if (fromEntitlementIds.length === 0) {
71+
if (!definitionsAreSame) return undefined;
72+
fromEntitlementIds.push(toEntitlement.id);
73+
}
6674

75+
const fromIsPooled = fromEntitlement.pooled === true;
76+
const toIsPooled = toEntitlement.pooled === true;
77+
const isPooledReplace = fromIsPooled && toIsPooled;
6778
const customerEntitlementPatch = computeCustomerEntitlementPatch({
6879
fromEntitlement,
6980
toEntitlement,
81+
carryOverUsages,
7082
});
71-
const fromIsPooled = fromEntitlement.pooled === true;
72-
const toIsPooled = toEntitlement.pooled === true;
73-
const isPooledReplace = fromIsPooled && toIsPooled;
7483
if (!isPooledReplace) {
7584
return {
7685
type: "replace",
@@ -82,10 +91,15 @@ const computeReplaceOperation = ({
8291
};
8392
}
8493

85-
const incrementAmount =
94+
const pooledContributionPatch =
8695
customerEntitlementPatch.balance?.type === "increment"
87-
? customerEntitlementPatch.balance.amount
88-
: 0;
96+
? customerEntitlementPatch.balance
97+
: {
98+
type: "set" as const,
99+
amount: computeCustomerEntitlementInitialState({
100+
entitlement: toEntitlement,
101+
}).granted,
102+
};
89103

90104
return {
91105
type: "replace",
@@ -96,7 +110,7 @@ const computeReplaceOperation = ({
96110
customerEntitlementPatch: {
97111
unlimited: customerEntitlementPatch.unlimited,
98112
},
99-
pooledContributionPatch: { type: "increment", amount: incrementAmount },
113+
pooledContributionPatch,
100114
};
101115
};
102116

@@ -196,11 +210,13 @@ const computeTransitionOperations = ({
196210
transition,
197211
initContext,
198212
initOptions,
213+
carryOverUsages,
199214
}: {
200215
candidateOutgoingEntitlements: EntitlementWithFeature[];
201216
transition: EntitlementPriceTransition;
202217
initContext: InitCustomerEntitlementContext;
203218
initOptions: InitFullCustomerProductOptions;
219+
carryOverUsages?: CarryOverUsages;
204220
}): EntitlementPriceOperation[] => {
205221
const fromEntitlement = transition.fromEntitlementPrice.entitlement;
206222
const toEntitlement = transition.toEntitlementPrice.entitlement;
@@ -215,6 +231,7 @@ const computeTransitionOperations = ({
215231
const operation = computeReplaceOperation({
216232
candidateOutgoingEntitlements,
217233
transition,
234+
carryOverUsages,
218235
});
219236
return operation ? [operation] : [];
220237
}
@@ -244,18 +261,21 @@ export const computeEntitlementPriceOperations = ({
244261
entitlementPriceTransitions,
245262
customerEntitlementInitContext,
246263
customerEntitlementInitOptions,
264+
carryOverUsages,
247265
}: {
248266
candidateOutgoingEntitlements: EntitlementWithFeature[];
249267
entitlementPriceTransitions: ComputedEntitlementPriceTransitions;
250268
customerEntitlementInitContext: InitCustomerEntitlementContext;
251269
customerEntitlementInitOptions: InitFullCustomerProductOptions;
270+
carryOverUsages?: CarryOverUsages;
252271
}): {
253272
operations: EntitlementPriceOperation[];
254273
unhandled: ComputedEntitlementPriceTransitions;
255274
} => {
256275
const operations: EntitlementPriceOperation[] = [];
257276
const unhandled: ComputedEntitlementPriceTransitions = {
258277
transitions: [],
278+
retained: [],
259279
added: [],
260280
deleted: [],
261281
};
@@ -275,10 +295,44 @@ export const computeEntitlementPriceOperations = ({
275295
transition,
276296
initContext: customerEntitlementInitContext,
277297
initOptions: customerEntitlementInitOptions,
298+
carryOverUsages,
278299
}),
279300
);
280301
}
281302

303+
for (const transition of entitlementPriceTransitions.retained) {
304+
if (
305+
isBooleanEntitlement({
306+
entitlement: transition.toEntitlementPrice.entitlement,
307+
})
308+
) {
309+
continue;
310+
}
311+
if (
312+
hasPrice(transition.fromEntitlementPrice) ||
313+
hasPrice(transition.toEntitlementPrice)
314+
) {
315+
unhandled.retained.push(transition);
316+
continue;
317+
}
318+
319+
if (
320+
shouldCarryOverUsage({
321+
toEntitlement: transition.toEntitlementPrice.entitlement,
322+
carryOverUsages,
323+
})
324+
) {
325+
continue;
326+
}
327+
328+
const operation = computeReplaceOperation({
329+
candidateOutgoingEntitlements,
330+
transition,
331+
carryOverUsages,
332+
});
333+
if (operation) operations.push(operation);
334+
}
335+
282336
for (const entitlementPrice of entitlementPriceTransitions.added) {
283337
if (hasPrice(entitlementPrice)) {
284338
unhandled.added.push(entitlementPrice);

server/src/internal/billing/v2/actions/batchTransition/compute/transitions/computeEntitlementPriceTransitions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type EntitlementPriceTransition = {
1414

1515
export type ComputedEntitlementPriceTransitions = {
1616
transitions: EntitlementPriceTransition[];
17+
/** Same-definition survivors. They still need a replace when usage resets. */
18+
retained: EntitlementPriceTransition[];
1719
added: EntitlementPrice[];
1820
deleted: EntitlementPrice[];
1921
};
@@ -53,6 +55,7 @@ export const computeEntitlementPriceTransitions = ({
5355
}
5456

5557
const transitions: EntitlementPriceTransition[] = [];
58+
const retained: EntitlementPriceTransition[] = [];
5659
const deleted: EntitlementPrice[] = [];
5760
fromEntitlementPrices.forEach((fromEntitlementPrice, fromIndex) => {
5861
const toEntitlementPrice = matchedByFromIndex.get(fromIndex);
@@ -73,13 +76,16 @@ export const computeEntitlementPriceTransitions = ({
7376
})
7477
) {
7578
transitions.push({ fromEntitlementPrice, toEntitlementPrice });
79+
return;
7680
}
81+
82+
retained.push({ fromEntitlementPrice, toEntitlementPrice });
7783
});
7884

7985
const added = toEntitlementPrices.filter(
8086
(toEntitlementPrice) =>
8187
!claimedToEntitlementIds.has(toEntitlementPrice.entitlement.id),
8288
);
8389

84-
return { transitions, added, deleted };
90+
return { transitions, retained, added, deleted };
8591
};

server/src/internal/billing/v2/actions/batchTransition/execute/executeCustomerEntitlementOperations.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,6 @@ const executeReplacement = async ({
2525
operation: ReplaceEntitlementPriceOperation;
2626
}) => {
2727
if (operation.fromEntitlementIds.length === 0) return 0;
28-
if (operation.fromEntitlementIds.includes(operation.toEntitlementId)) {
29-
throw new Error(
30-
"Batch replacement requires different outgoing and incoming entitlement IDs",
31-
);
32-
}
3328

3429
return executeBatchedMutation({
3530
db: ctx.db,

0 commit comments

Comments
 (0)