Skip to content

Commit 799406f

Browse files
committed
fix(billing): release assignments for dropped licenses
Automatically unassign entity licenses when a plan transition removes their license pool, preserve ambiguous-transition safeguards, and warn affected entities in the attach review.
1 parent 3cc25d6 commit 799406f

18 files changed

Lines changed: 399 additions & 16 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@autumn/shared";
77
import type { AutumnContext } from "@/honoUtils/HonoEnv";
88
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
9+
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
910
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions";
1011
import { cusProductToExistingBalanceCarryOvers } from "@/internal/billing/v2/utils/handleCarryOvers/cusProductToExistingBalanceCarryOvers";
1112
import { cusProductToOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/cusProductToOneOffPrepaidCarryOvers";
@@ -62,6 +63,14 @@ export const computeAttachPlan = ({
6263
: [];
6364
const customerLicenseTransitions =
6465
planTiming === "immediate" ? computedCustomerLicenseTransitions : [];
66+
const customerLicenseReleases =
67+
planTiming === "immediate" && currentCustomerProduct
68+
? computeCustomerLicenseReleases({
69+
outgoingCustomerProduct: currentCustomerProduct,
70+
incomingCustomerProduct: newCustomerProduct,
71+
releasedAt: attachBillingContext.currentEpochMs,
72+
})
73+
: {};
6574

6675
const {
6776
entitlements: carriedOverEntitlements,
@@ -133,6 +142,8 @@ export const computeAttachPlan = ({
133142
customFreeTrial: trialContext?.customFreeTrial,
134143
insertPlanLicenses: attachBillingContext.insertPlanLicenses,
135144
customerLicenseTransitions,
145+
releaseCustomerLicenseAssignments:
146+
customerLicenseReleases.releaseCustomerLicenseAssignments,
136147
lineItems,
137148
insertCustomerEntitlements: [
138149
...(carriedOverCustomerEntitlements ?? []),

server/src/internal/billing/v2/actions/attach/errors/handleLicenseErrors/handleDroppedLicenseErrors.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ const licensePlanIdOf = (customerLicense: FullCustomerLicense) =>
1111
customerLicense.planLicense?.product.id ??
1212
customerLicense.license_internal_product_id;
1313

14-
/** Blocks an immediate switch that would strand active assignments: a used
15-
* outgoing pool must have a successor (same license plan, or a 1:1 group
16-
* match) on the incoming plan. */
14+
/** Blocks ambiguous active assignment mappings; dropped pools release in compute. */
1715
export const handleDroppedLicenseErrors = ({
1816
billingContext,
1917
}: {
@@ -28,20 +26,18 @@ export const handleDroppedLicenseErrors = ({
2826
});
2927

3028
for (const { outgoingCustomerLicense, reason, group } of unmatched) {
29+
if (reason === "dropped") continue;
3130
const used = customerLicenseToUsage({
3231
customerLicense: outgoingCustomerLicense,
3332
});
3433
if (used === 0) continue;
3534

3635
const licensePlanId = licensePlanIdOf(outgoingCustomerLicense);
37-
const conflict =
38-
reason === "ambiguous"
39-
? `the licenses in group "${group}" are not a 1:1 match on the incoming plan`
40-
: "the incoming plan drops the license";
4136
throw new RecaseError({
4237
message:
4338
`License changes conflict with active license assignments: ` +
44-
`${used} assigned for ${licensePlanId}, but ${conflict}. Release licenses first.`,
39+
`${used} assigned for ${licensePlanId}, but the licenses in group "${group}" ` +
40+
"are not a 1:1 match on the incoming plan. Release licenses first.",
4541
code: ErrCode.InvalidRequest,
4642
statusCode: 400,
4743
});

server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
type AutumnBillingPlan,
3-
type CustomerLicenseUpdate,
43
CusProductStatus,
4+
type CustomerLicenseUpdate,
55
type Entitlement,
66
type FullCusProduct,
77
type InsertPlanLicenseSpec,
@@ -10,6 +10,7 @@ import {
1010
} from "@autumn/shared";
1111
import type { AutumnContext } from "@/honoUtils/HonoEnv";
1212
import { computeCustomerLicenseQuantityChanges } from "@/internal/billing/v2/compute/computeCustomerLicenseQuantityChanges";
13+
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
1314
import { resolveSyncExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/resolveSyncExistingUsagesConfig";
1415
import { initImmediateSyncCustomerProduct } from "./initImmediateSyncCustomerProduct";
1516

@@ -24,6 +25,9 @@ export type ImmediatePhaseResult = {
2425
customEntitlements: Entitlement[];
2526
insertPlanLicenses: InsertPlanLicenseSpec[];
2627
customerLicenseUpdates: CustomerLicenseUpdate[];
28+
releaseCustomerLicenseAssignments?: NonNullable<
29+
AutumnBillingPlan["releaseCustomerLicenseAssignments"]
30+
>;
2731
};
2832

2933
const expireCustomerProduct = ({
@@ -81,6 +85,7 @@ export const computeSyncImmediatePhase = ({
8185
const customEntitlements: Entitlement[] = [];
8286
const insertPlanLicenses: InsertPlanLicenseSpec[] = [];
8387
const customerLicenseUpdates: CustomerLicenseUpdate[] = [];
88+
const droppedCustomerLicenseLinkIds: string[] = [];
8489

8590
for (const productContext of immediatePhase.productContexts) {
8691
const currentCustomerProduct = productContext.currentCustomerProduct;
@@ -121,6 +126,14 @@ export const computeSyncImmediatePhase = ({
121126
insertPlanLicenses.push(...(productContext.insertPlanLicenses ?? []));
122127

123128
if (productContext.currentCustomerProduct) {
129+
const release = computeCustomerLicenseReleases({
130+
outgoingCustomerProduct: productContext.currentCustomerProduct,
131+
incomingCustomerProduct: insertedCustomerProduct,
132+
releasedAt: currentEpochMs,
133+
}).releaseCustomerLicenseAssignments;
134+
droppedCustomerLicenseLinkIds.push(
135+
...(release?.customerLicenseLinkIds ?? []),
136+
);
124137
updateCustomerProducts.push(
125138
expireCustomerProduct({
126139
customerProduct: productContext.currentCustomerProduct,
@@ -137,5 +150,11 @@ export const computeSyncImmediatePhase = ({
137150
customEntitlements,
138151
insertPlanLicenses,
139152
customerLicenseUpdates,
153+
releaseCustomerLicenseAssignments: droppedCustomerLicenseLinkIds.length
154+
? {
155+
customerLicenseLinkIds: droppedCustomerLicenseLinkIds,
156+
releasedAt: currentEpochMs,
157+
}
158+
: undefined,
140159
};
141160
};

server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export const computeSyncPlan = ({
7373
immediate.customerLicenseUpdates.length > 0
7474
? immediate.customerLicenseUpdates
7575
: undefined,
76+
releaseCustomerLicenseAssignments:
77+
immediate.releaseCustomerLicenseAssignments,
7678
lockCustomerCurrency: syncContextToCurrencyLock({ syncContext }),
7779
upsertSubscription,
7880
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { AutumnBillingPlan, FullCusProduct } from "@autumn/shared";
2+
import { matchCustomerLicenseSuccessors } from "./matchCustomerLicenseSuccessors.js";
3+
4+
export const computeCustomerLicenseReleases = ({
5+
outgoingCustomerProduct,
6+
incomingCustomerProduct,
7+
releasedAt,
8+
}: {
9+
outgoingCustomerProduct: FullCusProduct;
10+
incomingCustomerProduct?: FullCusProduct;
11+
releasedAt: number;
12+
}): Pick<AutumnBillingPlan, "releaseCustomerLicenseAssignments"> => {
13+
const { unmatched } = matchCustomerLicenseSuccessors({
14+
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
15+
incomingCustomerLicenses: incomingCustomerProduct?.customer_licenses ?? [],
16+
});
17+
const customerLicenseLinkIds = unmatched.flatMap(
18+
({ outgoingCustomerLicense, reason }) =>
19+
reason === "dropped" ? [outgoingCustomerLicense.link_id] : [],
20+
);
21+
22+
return {
23+
releaseCustomerLicenseAssignments: customerLicenseLinkIds.length
24+
? { customerLicenseLinkIds, releasedAt }
25+
: undefined,
26+
};
27+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { AutumnBillingPlan } from "@autumn/shared";
2+
import type { AutumnContext } from "@/honoUtils/HonoEnv";
3+
import { licenseAssignmentRepo } from "@/internal/licenses/repos/licenseAssignmentRepo";
4+
5+
export const executeCustomerLicenseAssignmentReleases = async ({
6+
ctx,
7+
release,
8+
}: {
9+
ctx: AutumnContext;
10+
release: AutumnBillingPlan["releaseCustomerLicenseAssignments"];
11+
}) => {
12+
if (!release) return;
13+
await licenseAssignmentRepo.releaseActiveAssignmentsByLinkIds({
14+
db: ctx.db,
15+
...release,
16+
});
17+
};

server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type Stripe from "stripe";
33
import type { AutumnContext } from "@/honoUtils/HonoEnv";
44
import { EntityService } from "@/internal/api/entities/EntityService";
55
import { executeAutoTopupRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance";
6+
import { executeCustomerLicenseAssignmentReleases } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseAssignmentReleases";
67
import { executeCustomerLicenseTransitions } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions";
78
import { executeCustomerLicenseUpdates } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseUpdates";
89
import { executeInsertPlanLicenses } from "@/internal/billing/v2/execute/executeAutumnActions/executeInsertPlanLicenses";
@@ -117,6 +118,10 @@ export const executeAutumnBillingPlan = async ({
117118
ctx,
118119
customerLicenseTransitions: autumnBillingPlan.customerLicenseTransitions,
119120
});
121+
await executeCustomerLicenseAssignmentReleases({
122+
ctx,
123+
release: autumnBillingPlan.releaseCustomerLicenseAssignments,
124+
});
120125

121126
await replaceScheduledPhaseCustomerProductIds({
122127
ctx,

server/src/internal/billing/v2/utils/billingPlan/mergeAutumnBillingPlans.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ export const mergeAutumnBillingPlans = ({
100100
upsertSubscription: incoming.upsertSubscription ?? base.upsertSubscription,
101101
upsertInvoice: incoming.upsertInvoice ?? base.upsertInvoice,
102102
refundPlan: incoming.refundPlan ?? base.refundPlan,
103+
releaseCustomerLicenseAssignments:
104+
base.releaseCustomerLicenseAssignments ||
105+
incoming.releaseCustomerLicenseAssignments
106+
? {
107+
customerLicenseLinkIds: [
108+
...new Set([
109+
...(base.releaseCustomerLicenseAssignments
110+
?.customerLicenseLinkIds ?? []),
111+
...(incoming.releaseCustomerLicenseAssignments
112+
?.customerLicenseLinkIds ?? []),
113+
]),
114+
],
115+
releasedAt: Math.max(
116+
base.releaseCustomerLicenseAssignments?.releasedAt ?? 0,
117+
incoming.releaseCustomerLicenseAssignments?.releasedAt ?? 0,
118+
),
119+
}
120+
: undefined,
103121
});
104122

105123
const mergeById = <T extends { id: string }>({

server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
type FullProduct,
77
} from "@autumn/shared";
88
import type { AutumnContext } from "@/honoUtils/HonoEnv";
9+
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
10+
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions";
911
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
1012
import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct";
1113
import { productActions } from "@/internal/products/actions";
@@ -67,13 +69,25 @@ export const activateFreeDefaultProduct = async ({
6769
},
6870
},
6971
});
72+
const customerLicenseTransitions = computeCustomerLicenseTransitions({
73+
outgoingCustomerProducts: [customerProduct],
74+
incomingCustomerProducts: [newCustomerProduct],
75+
});
76+
const customerLicenseReleases = computeCustomerLicenseReleases({
77+
outgoingCustomerProduct: customerProduct,
78+
incomingCustomerProduct: newCustomerProduct,
79+
releasedAt: Date.now(),
80+
});
7081

7182
// 3. Execute autumn billing plan
7283
await executeAutumnBillingPlan({
7384
ctx,
7485
autumnBillingPlan: {
7586
customerId: fullCustomer?.id ?? "",
7687
insertCustomerProducts: [newCustomerProduct],
88+
customerLicenseTransitions,
89+
releaseCustomerLicenseAssignments:
90+
customerLicenseReleases.releaseCustomerLicenseAssignments,
7791
},
7892
});
7993

server/src/internal/customers/cusProducts/actions/activateScheduled.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "@autumn/shared";
99
import type { AutumnContext } from "@/honoUtils/HonoEnv";
1010
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
11+
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases.js";
1112
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions.js";
1213
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
1314
import { findTransitionSourceCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct";
@@ -65,6 +66,13 @@ export const activateScheduledCustomerProduct = async ({
6566
incomingCustomerProducts: [customerProduct],
6667
})
6768
: [];
69+
const customerLicenseReleases = transitionSource
70+
? computeCustomerLicenseReleases({
71+
outgoingCustomerProduct: transitionSource,
72+
incomingCustomerProduct: customerProduct,
73+
releasedAt: Date.now(),
74+
})
75+
: {};
6876

6977
// Executing through the shared plan runs the license lifecycle for
7078
// activations that bring license-bearing parents live.
@@ -80,6 +88,8 @@ export const activateScheduledCustomerProduct = async ({
8088
},
8189
],
8290
customerLicenseTransitions,
91+
releaseCustomerLicenseAssignments:
92+
customerLicenseReleases.releaseCustomerLicenseAssignments,
8393
},
8494
});
8595

0 commit comments

Comments
 (0)