Skip to content

Commit f1a49c6

Browse files
committed
feat(licenses): 🎸 support catalog license plan updates
Add license-aware plan update previews, preserve customer definitions across versioned and in-place edits, and surface the resulting catalog changes in the dashboard.
1 parent 90273f6 commit f1a49c6

55 files changed

Lines changed: 2094 additions & 290 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

server/src/internal/catalog/actions/catalogPlanPreflight.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
previewPlanLicenseSync,
99
validatePlanLicenseUpdate,
1010
} from "@/internal/licenses/actions/links/syncPlanLicenses.js";
11+
import { previewAffectedLicenses } from "@/internal/product/actions/previewUpdatePlan/previewAffectedLicenses.js";
1112
import { ProductService } from "@/internal/products/ProductService.js";
1213
import { sortCatalogPlansByDependencies } from "./catalogPlanDependencies.js";
1314

@@ -90,7 +91,15 @@ const preflightCatalogLicenses = async ({
9091
newParentVersion: preview.versionable || !current,
9192
licenseProducts,
9293
});
93-
preview.license_changes = licensePreview.changes;
94+
preview.license_changes = await previewAffectedLicenses({
95+
ctx,
96+
currentParentProduct: {
97+
...parent,
98+
licenses: current?.licenses ?? [],
99+
},
100+
resolved: licensePreview.prepared?.resolved ?? [],
101+
structuralChanges: licensePreview.changes,
102+
});
94103
if (preview.plan) preview.plan.licenses = licensePreview.licenses;
95104
}
96105
};

server/src/internal/licenses/actions/customize/setupCustomPlanLicenses.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDeepStrictEqual } from "node:util";
12
import type {
23
CustomizePlanLicense,
34
DbPlanLicense,
@@ -45,8 +46,7 @@ const matchesCatalogLink = ({
4546
return false;
4647
if (
4748
entry.metadata !== undefined &&
48-
JSON.stringify(entry.metadata) !==
49-
JSON.stringify(catalogLink.metadata ?? {})
49+
!isDeepStrictEqual(entry.metadata, catalogLink.metadata ?? {})
5050
)
5151
return false;
5252
return true;

server/src/internal/licenses/actions/customize/toApiPlanLicenseWithCustomize.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,28 @@ import {
22
type ApiPlanV1,
33
diffPlanV1,
44
type FullPlanLicense,
5+
type LicenseCustomize,
56
} from "@autumn/shared";
67
import { toApiPlanLicenses } from "@/internal/licenses/licenseUtils.js";
78

9+
export const diffLicensePlanCustomize = ({
10+
basePlan,
11+
effectivePlan,
12+
}: {
13+
basePlan: ApiPlanV1;
14+
effectivePlan: ApiPlanV1;
15+
}): LicenseCustomize | undefined => {
16+
const diff = diffPlanV1({ from: basePlan, to: effectivePlan });
17+
const customize = {
18+
...(diff.price !== undefined ? { price: diff.price } : {}),
19+
...(diff.add_items !== undefined ? { add_items: diff.add_items } : {}),
20+
...(diff.remove_items !== undefined
21+
? { remove_items: diff.remove_items }
22+
: {}),
23+
};
24+
return Object.keys(customize).length > 0 ? customize : undefined;
25+
};
26+
827
export const toApiPlanLicenseWithCustomize = async ({
928
license,
1029
resolvePlan,
@@ -19,16 +38,10 @@ export const toApiPlanLicenseWithCustomize = async ({
1938
resolvePlan(license.base_product),
2039
resolvePlan(license.product),
2140
]);
22-
const diff = diffPlanV1({ from: basePlan, to: effectivePlan });
41+
const customize = diffLicensePlanCustomize({ basePlan, effectivePlan });
2342

2443
return {
2544
...response,
26-
customize: {
27-
...(diff.price !== undefined ? { price: diff.price } : {}),
28-
...(diff.add_items !== undefined ? { add_items: diff.add_items } : {}),
29-
...(diff.remove_items !== undefined
30-
? { remove_items: diff.remove_items }
31-
: {}),
32-
},
45+
...(customize ? { customize } : {}),
3346
};
3447
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { isDeepStrictEqual } from "node:util";
2+
import type { DbPlanLicense, PlanLicenseParams } from "@autumn/shared";
3+
import type { DrizzleCli } from "@/db/initDrizzle.js";
4+
import { planLicenseRepo } from "@/internal/licenses/repos/planLicenseRepo.js";
5+
6+
type ItemCustomizationMode = "preserve" | "clear" | "replace";
7+
8+
const planLicenseWillChange = ({
9+
current,
10+
entry,
11+
included,
12+
prepaidOnly,
13+
itemCustomizationMode,
14+
}: {
15+
current: DbPlanLicense;
16+
entry: PlanLicenseParams;
17+
included: number;
18+
prepaidOnly: boolean;
19+
itemCustomizationMode: ItemCustomizationMode;
20+
}) =>
21+
current.included !== included ||
22+
current.prepaid_only !== prepaidOnly ||
23+
(entry.metadata !== undefined &&
24+
!isDeepStrictEqual(current.metadata ?? {}, entry.metadata)) ||
25+
itemCustomizationMode === "replace" ||
26+
(itemCustomizationMode === "clear" && current.customized);
27+
28+
/** Retires a changed catalog link when a customer still references its definition. */
29+
export const retireCatalogPlanLicenseIfReferenced = async ({
30+
db,
31+
current,
32+
entry,
33+
included,
34+
prepaidOnly,
35+
itemCustomizationMode,
36+
hasCustomerReference,
37+
}: {
38+
db: DrizzleCli;
39+
current: DbPlanLicense;
40+
entry: PlanLicenseParams;
41+
included: number;
42+
prepaidOnly: boolean;
43+
itemCustomizationMode: ItemCustomizationMode;
44+
hasCustomerReference: boolean;
45+
}) => {
46+
if (
47+
!planLicenseWillChange({
48+
current,
49+
entry,
50+
included,
51+
prepaidOnly,
52+
itemCustomizationMode,
53+
})
54+
) {
55+
return;
56+
}
57+
58+
if (!hasCustomerReference) return;
59+
60+
await planLicenseRepo.retireCatalogById({ db, id: current.id });
61+
};

server/src/internal/licenses/actions/links/syncPlanLicenses.ts

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type DbPlanLicense,
23
type Entitlement,
34
ErrCode,
45
type FullProduct,
@@ -18,14 +19,20 @@ import {
1819
getFullLicenseProduct,
1920
toApiPlanLicenses,
2021
} from "../../licenseUtils.js";
22+
import { customerLicenseRepo } from "../../repos/customerLicenseRepo.js";
2123
import { licenseAssignmentRepo } from "../../repos/licenseAssignmentRepo.js";
2224
import { licenseItemRepo } from "../../repos/licenseItemRepo.js";
2325
import { planLicenseRepo } from "../../repos/planLicenseRepo.js";
2426
import { logLicenseAction } from "../logs/logLicenseAction.js";
27+
import { retireCatalogPlanLicenseIfReferenced } from "./retireCatalogPlanLicenseIfReferenced.js";
2528
import { validateLicenseLink } from "./validateLicenseLink.js";
2629

2730
type LicenseItemCustomization =
28-
| { mode: "preserve"; sourcePlanLicenseId?: string }
31+
| {
32+
mode: "preserve";
33+
sourcePlanLicenseId?: string;
34+
sourceCustomized?: boolean;
35+
}
2936
| { mode: "clear" }
3037
| {
3138
mode: "replace";
@@ -34,17 +41,19 @@ type LicenseItemCustomization =
3441
items: ReturnType<typeof derivePlanLicenseItemRefs>;
3542
};
3643

37-
type ResolvedLink = {
44+
export type ResolvedPlanLicenseLink = {
3845
entry: PlanLicenseParams;
3946
licenseProduct: FullProduct;
47+
effectiveProduct: FullProduct;
4048
included: number;
4149
prepaidOnly: boolean;
4250
itemCustomization: LicenseItemCustomization;
51+
sourcePlanLicense?: DbPlanLicense;
4352
};
4453

4554
export type PreparedPlanLicenseSync = {
4655
parentProduct: FullProduct;
47-
resolved: ResolvedLink[];
56+
resolved: ResolvedPlanLicenseLink[];
4857
removed: Awaited<
4958
ReturnType<typeof planLicenseRepo.listCatalogByParentInternalProductIds>
5059
>;
@@ -98,7 +107,7 @@ export const previewPlanLicenseSync = async (
98107
];
99108
},
100109
);
101-
return { licenses: current, changes };
110+
return { licenses: current, changes, prepared };
102111
};
103112

104113
/** Resolves and validates one link without writing it. */
@@ -121,7 +130,7 @@ const resolveLink = async ({
121130
PreparedPlanLicenseSync["removed"][number]
122131
>;
123132
sourceProductByLicenseInternalId: Map<string, FullProduct>;
124-
}): Promise<ResolvedLink> => {
133+
}): Promise<ResolvedPlanLicenseLink> => {
125134
const pinnedInternalId =
126135
entry.version === undefined
127136
? pinnedInternalIdByPublicId.get(entry.license_plan_id)
@@ -152,6 +161,7 @@ const resolveLink = async ({
152161
itemCustomization = {
153162
mode: "preserve",
154163
sourcePlanLicenseId: sourceLink?.id,
164+
sourceCustomized: sourceLink?.customized,
155165
};
156166
if (sourceLink?.customized) {
157167
effectiveProduct =
@@ -185,12 +195,36 @@ const resolveLink = async ({
185195
return {
186196
entry,
187197
licenseProduct,
198+
effectiveProduct,
188199
included: entry.included ?? 0,
189200
prepaidOnly: entry.prepaid_only ?? true,
190201
itemCustomization,
202+
sourcePlanLicense: sourceLink,
191203
};
192204
};
193205

206+
/** Nesting is not supported: a plan offered as a license under other plans
207+
* cannot offer licenses of its own. Clearing (`licenses: []`) stays allowed. */
208+
const assertParentNotLicensed = ({
209+
parentProduct,
210+
licenses,
211+
}: {
212+
parentProduct: FullProduct;
213+
licenses: PlanLicenseParams[];
214+
}) => {
215+
const parentIds = [
216+
...new Set(
217+
(parentProduct.parent_plan_licenses ?? []).map((link) => link.product.id),
218+
),
219+
];
220+
if (licenses.length === 0 || parentIds.length === 0) return;
221+
throw new RecaseError({
222+
message: `Cannot add licenses to ${parentProduct.id}: it is offered as a license under ${parentIds.join(", ")}.`,
223+
code: ErrCode.InvalidRequest,
224+
statusCode: 400,
225+
});
226+
};
227+
194228
/** A plan cannot drop below what customers are already using. */
195229
const assertCapacityAllowed = async ({
196230
ctx,
@@ -246,20 +280,23 @@ export const preparePlanLicenseSync = async ({
246280
statusCode: 400,
247281
});
248282
}
283+
assertParentNotLicensed({ parentProduct, licenses });
249284

250285
const sourceLinks =
251-
await planLicenseRepo.listCatalogByParentInternalProductIds({
286+
parentProduct.licenses
287+
?.filter(
288+
(link) => link.parent_internal_product_id === fromInternalProductId,
289+
)
290+
.map(({ product: _, base_product: __, ...link }) => link) ??
291+
(await planLicenseRepo.listCatalogByParentInternalProductIds({
252292
db: ctx.db,
253293
parentInternalProductIds: [fromInternalProductId],
254-
});
255-
const existingLinkProducts = await planLicenseRepo.listProductsByInternalIds({
256-
db: ctx.db,
257-
internalProductIds: sourceLinks.map(
258-
(link) => link.license_internal_product_id,
259-
),
260-
});
294+
}));
261295
const pinnedInternalIdByPublicId = new Map(
262-
existingLinkProducts.map((product) => [product.id, product.internal_id]),
296+
(parentProduct.licenses ?? []).map((link) => [
297+
link.product.id,
298+
link.license_internal_product_id,
299+
]),
263300
);
264301
const sourceLinkByLicenseInternalId = new Map(
265302
sourceLinks.map((link) => [link.license_internal_product_id, link]),
@@ -352,7 +389,37 @@ export const applyPreparedPlanLicenseSync = async ({
352389

353390
await ctx.db.transaction(async (tx) => {
354391
const txDb = tx as unknown as DrizzleCli;
392+
const sourcePlanLicenses = resolved.flatMap(({ sourcePlanLicense }) =>
393+
sourcePlanLicense?.parent_internal_product_id === parentInternalProductId
394+
? [sourcePlanLicense]
395+
: [],
396+
);
397+
const referencedPlanLicenseIds =
398+
await customerLicenseRepo.listReferencedPlanLicenseIds({
399+
db: txDb,
400+
planLicenseIds: sourcePlanLicenses.map(({ id }) => id),
401+
});
402+
const sourceLinkByLicenseInternalId = new Map(
403+
sourcePlanLicenses.map((link) => [
404+
link.license_internal_product_id,
405+
link,
406+
]),
407+
);
355408
for (const link of resolved) {
409+
const current = sourceLinkByLicenseInternalId.get(
410+
link.licenseProduct.internal_id,
411+
);
412+
if (current) {
413+
await retireCatalogPlanLicenseIfReferenced({
414+
db: txDb,
415+
current,
416+
entry: link.entry,
417+
included: link.included,
418+
prepaidOnly: link.prepaidOnly,
419+
itemCustomizationMode: link.itemCustomization.mode,
420+
hasCustomerReference: referencedPlanLicenseIds.has(current.id),
421+
});
422+
}
356423
const planLicense = await planLicenseRepo.upsert({
357424
db: txDb,
358425
parentInternalProductId,
@@ -388,7 +455,7 @@ export const applyPreparedPlanLicenseSync = async ({
388455
db: txDb,
389456
fromPlanLicenseId: customization.sourcePlanLicenseId,
390457
toPlanLicenseId: planLicense.id,
391-
customized: true,
458+
customized: customization.sourceCustomized,
392459
});
393460
}
394461
}

server/src/internal/licenses/repos/customerLicenseRepo.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,25 @@ const listByParentCustomerProductIds = async ({
8787
});
8888
};
8989

90+
const listReferencedPlanLicenseIds = async ({
91+
db,
92+
planLicenseIds,
93+
}: {
94+
db: DrizzleCli;
95+
planLicenseIds: string[];
96+
}): Promise<Set<string>> => {
97+
if (planLicenseIds.length === 0) return new Set();
98+
const rows = await db
99+
.select({ planLicenseId: customerLicenses.plan_license_id })
100+
.from(customerLicenses)
101+
.where(inArray(customerLicenses.plan_license_id, planLicenseIds));
102+
return new Set(
103+
rows.flatMap(({ planLicenseId }) =>
104+
planLicenseId === null ? [] : [planLicenseId],
105+
),
106+
);
107+
};
108+
90109
/** Idempotent ensure + granted sync for a (parent, license) balance row.
91110
* Stamps plan_license_id when provided so stale links converge too. */
92111
const upsertGranted = async ({
@@ -330,6 +349,7 @@ export const customerLicenseRepo = {
330349
getByParentAndLicense,
331350
listByInternalCustomerId,
332351
listByParentCustomerProductIds,
352+
listReferencedPlanLicenseIds,
333353
listBillingPriceRows,
334354
update,
335355
deleteByIds,

0 commit comments

Comments
 (0)