Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -19,6 +19,7 @@ import { handleProrationBehaviorErrors } from "@/internal/billing/v2/common/erro
import { handleCustomLineItemsErrors } from "@/internal/billing/v2/common/errors/handleCustomLineItemsErrors";
import { handleEntityLicenseAssignmentErrors } from "@/internal/billing/v2/common/errors/handleEntityLicenseAssignmentErrors";
import { handleExternalPSPErrors } from "@/internal/billing/v2/common/errors/handleExternalPSPErrors";
import { handleLicenseAttachTargetErrors } from "@/internal/billing/v2/common/errors/handleLicenseAttachTargetErrors";
import { handleSubscriptionIdErrors } from "@/internal/billing/v2/common/errors/handleSubscriptionIdErrors";
import { handleStripeBillingPlanErrors } from "@/internal/billing/v2/providers/stripe/errors/handleStripeBillingPlanErrors";
import { handleCustomPaymentMethodErrorsV2 } from "@/internal/customers/attach/attachUtils/handleAttachErrors";
Expand Down Expand Up @@ -60,6 +61,12 @@ export const handleAttachV2Errors = async ({
fullCustomer: billingContext.fullCustomer,
});

// 1.4. License linkage preconditions on the attach target
handleLicenseAttachTargetErrors({
fullCustomer: billingContext.fullCustomer,
attachProduct: billingContext.attachProduct,
});

// 2. Current customer product errors (same product)
handleCurrentCustomerProductErrors({ billingContext });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import {
type FullCustomerLicense,
RecaseError,
} from "@autumn/shared";
import { matchCustomerLicenseSuccessors } from "@/internal/billing/v2/compute/customerLicenseTransitions/matchCustomerLicenseSuccessors.js";

const licensePlanIdOf = (customerLicense: FullCustomerLicense) =>
customerLicense.planLicense?.product.id ??
customerLicense.license_internal_product_id;

/** Blocks an immediate switch that would strand active assignments: a used
* outgoing pool must have a successor (same license plan, or a 1:1 group
* match) on the incoming plan. */
export const handleDroppedLicenseErrors = ({
billingContext,
autumnBillingPlan,
Expand All @@ -21,21 +25,28 @@ export const handleDroppedLicenseErrors = ({
const { currentCustomerProduct, planTiming } = billingContext;
if (planTiming !== "immediate" || !currentCustomerProduct) return;

const incomingLicensePlanIds = new Set(
(autumnBillingPlan.insertCustomerProducts ?? []).flatMap(
(customerProduct) =>
(customerProduct.customer_licenses ?? []).map(licensePlanIdOf),
),
);
const { unmatched } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: currentCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: (
autumnBillingPlan.insertCustomerProducts ?? []
).flatMap((customerProduct) => customerProduct.customer_licenses ?? []),
});

for (const outgoingPool of currentCustomerProduct.customer_licenses ?? []) {
const used = customerLicenseToUsage({ customerLicense: outgoingPool });
for (const { outgoingCustomerLicense, reason, group } of unmatched) {
const used = customerLicenseToUsage({
customerLicense: outgoingCustomerLicense,
});
if (used === 0) continue;
if (incomingLicensePlanIds.has(licensePlanIdOf(outgoingPool))) continue;

const licensePlanId = licensePlanIdOf(outgoingCustomerLicense);
const conflict =
reason === "ambiguous"
? `the licenses in group "${group}" are not a 1:1 match on the incoming plan`
: "the incoming plan drops the license";
throw new RecaseError({
message:
`License changes conflict with active license assignments: ` +
`${used} assigned for ${licensePlanIdOf(outgoingPool)}, but the incoming plan drops the license. Release licenses first.`,
`${used} assigned for ${licensePlanId}, but ${conflict}. Release licenses first.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
ErrCode,
type FullCustomer,
type FullProduct,
RecaseError,
} from "@autumn/shared";

/** The customer's plan offering this license, if any: matched via their
* pools (survives version pins) or the license's catalog parent links
* (covers links added after the parent was attached). */
const findLicenseParentPlanId = ({
fullCustomer,
licenseProduct,
}: {
fullCustomer: FullCustomer;
licenseProduct: FullProduct;
}): string | null => {
const parentInternalProductIds = new Set(
(licenseProduct.parent_plan_licenses ?? []).map(
(link) => link.parent_internal_product_id,
),
);

for (const customerProduct of fullCustomer.customer_products) {
if (parentInternalProductIds.has(customerProduct.internal_product_id)) {
return customerProduct.product.id;
}
const pool = (customerProduct.customer_licenses ?? []).find(
(customerLicense) =>
customerLicense.planLicense?.product.id === licenseProduct.id,
);
if (pool) return customerProduct.product.id;
}
return null;
};

/** License linkage preconditions on the attach target: plans offering
* licenses live on the customer, and licensed seats go through the parent. */
export const handleLicenseAttachTargetErrors = ({
fullCustomer,
attachProduct,
}: {
fullCustomer: FullCustomer;
attachProduct: FullProduct;
}) => {
const entity = fullCustomer.entity;
if (entity && attachProduct.licenses?.length) {
throw new RecaseError({
message:
`Plan ${attachProduct.id} offers licenses, so it can only be attached ` +
`to the customer — not to entity ${entity.id ?? entity.internal_id}.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}

const parentPlanId = findLicenseParentPlanId({
fullCustomer,
licenseProduct: attachProduct,
});
if (parentPlanId) {
throw new RecaseError({
message:
`Plan ${attachProduct.id} is a license under ${parentPlanId}, which ` +
`this customer is on. Assign seats with licenses.attach instead of ` +
`attaching it directly.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { FullCustomerLicense } from "@autumn/shared";

export type CustomerLicenseSuccessorMatch = {
outgoingCustomerLicense: FullCustomerLicense;
incomingCustomerLicense: FullCustomerLicense;
};

export type UnmatchedOutgoingCustomerLicense = {
outgoingCustomerLicense: FullCustomerLicense;
reason: "dropped" | "ambiguous";
group?: string;
};

const licensePlanIdOf = (customerLicense: FullCustomerLicense) =>
customerLicense.planLicense?.product.id ??
customerLicense.license_internal_product_id;

/** Empty group never cross-pairs — grouping is the explicit opt-in for
* "this is the same seat across plans". */
const licenseGroupOf = (customerLicense: FullCustomerLicense) =>
customerLicense.planLicense?.product.group || null;

/**
* Ranked successor selection for license pools across a parent plan
* transition: the same license plan id always wins; otherwise pools pair by
* their license plan's group, but only when the group resolves 1:1 among the
* pools no id claimed. Several candidates on either side match nothing.
*/
export const matchCustomerLicenseSuccessors = ({
outgoingCustomerLicenses,
incomingCustomerLicenses,
}: {
outgoingCustomerLicenses: FullCustomerLicense[];
incomingCustomerLicenses: FullCustomerLicense[];
}): {
matches: CustomerLicenseSuccessorMatch[];
unmatched: UnmatchedOutgoingCustomerLicense[];
} => {
const incomingByLicensePlanId = new Map<string, FullCustomerLicense>();
for (const incoming of incomingCustomerLicenses) {
const licensePlanId = licensePlanIdOf(incoming);
if (!incomingByLicensePlanId.has(licensePlanId)) {
incomingByLicensePlanId.set(licensePlanId, incoming);
}
}

const idClaimedIncoming = new Set<FullCustomerLicense>();
for (const outgoing of outgoingCustomerLicenses) {
const claimed = incomingByLicensePlanId.get(licensePlanIdOf(outgoing));
if (claimed) idClaimedIncoming.add(claimed);
}

const incomingByGroup = new Map<string, FullCustomerLicense[]>();
for (const incoming of incomingCustomerLicenses) {
if (idClaimedIncoming.has(incoming)) continue;
const group = licenseGroupOf(incoming);
if (!group) continue;
const rows = incomingByGroup.get(group);
if (rows) rows.push(incoming);
else incomingByGroup.set(group, [incoming]);
}

const outgoingGroupCounts = new Map<string, number>();
for (const outgoing of outgoingCustomerLicenses) {
if (incomingByLicensePlanId.has(licensePlanIdOf(outgoing))) continue;
const group = licenseGroupOf(outgoing);
if (!group) continue;
outgoingGroupCounts.set(group, (outgoingGroupCounts.get(group) ?? 0) + 1);
}

const matches: CustomerLicenseSuccessorMatch[] = [];
const unmatched: UnmatchedOutgoingCustomerLicense[] = [];
for (const outgoing of outgoingCustomerLicenses) {
const idMatch = incomingByLicensePlanId.get(licensePlanIdOf(outgoing));
if (idMatch) {
matches.push({
outgoingCustomerLicense: outgoing,
incomingCustomerLicense: idMatch,
});
continue;
}

const group = licenseGroupOf(outgoing);
const candidates = group ? (incomingByGroup.get(group) ?? []) : [];
if (candidates.length === 0) {
unmatched.push({ outgoingCustomerLicense: outgoing, reason: "dropped" });
continue;
}
const ambiguous =
candidates.length > 1 || (outgoingGroupCounts.get(group ?? "") ?? 0) > 1;
if (ambiguous) {
unmatched.push({
outgoingCustomerLicense: outgoing,
reason: "ambiguous",
group: group ?? undefined,
});
continue;
}
matches.push({
outgoingCustomerLicense: outgoing,
incomingCustomerLicense: candidates[0],
});
}

return { matches, unmatched };
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
FullCustomerLicense,
FullPlanLicense,
} from "@autumn/shared";
import { matchCustomerLicenseSuccessors } from "./matchCustomerLicenseSuccessors.js";

/** A pair always carries both effective licenses — rows with a dead link
* never pair, so consumers need no null handling. The parent customer
Expand All @@ -16,60 +17,37 @@ export type CustomerLicensePair = {
incomingPlanLicense: FullPlanLicense;
};

type CustomerLicenseWithPlanLicense = {
customerLicense: FullCustomerLicense;
planLicense: FullPlanLicense;
};

/** Pairs customer licenses within an already-known customer product transition.
* Public product ids survive license-plan version changes. */
/** Pairs customer licenses within an already-known customer product
* transition. Same license plan ids pair first (public ids survive version
* changes); cross-plan pools pair 1:1 by license plan group. */
export const pairCustomerLicensesByLicensePlan = ({
outgoingCustomerProduct,
incomingCustomerProduct,
}: {
outgoingCustomerProduct: FullCusProduct;
incomingCustomerProduct: FullCusProduct;
}): CustomerLicensePair[] => {
const incomingByLicensePlanId = new Map<
string,
CustomerLicenseWithPlanLicense
>();

const incomingCustomerLicenses =
incomingCustomerProduct.customer_licenses ?? [];
const outgoingCustomerLicenses =
outgoingCustomerProduct.customer_licenses ?? [];

for (const customerLicense of incomingCustomerLicenses) {
const planLicense = customerLicense.planLicense;
if (!planLicense) continue;

const licensePlanId = planLicense.product.id;
if (incomingByLicensePlanId.has(licensePlanId)) continue;

incomingByLicensePlanId.set(licensePlanId, {
customerLicense,
planLicense,
});
}

const pairs: CustomerLicensePair[] = [];
for (const customerLicense of outgoingCustomerLicenses) {
const planLicense = customerLicense.planLicense;
if (!planLicense) continue;

const incoming = incomingByLicensePlanId.get(planLicense.product.id);
if (!incoming) continue;

pairs.push({
outgoingCustomerProduct,
incomingCustomerProduct,
outgoingCustomerLicense: customerLicense,
incomingCustomerLicense: incoming.customerLicense,
outgoingPlanLicense: planLicense,
incomingPlanLicense: incoming.planLicense,
});
}

return pairs;
const { matches } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
Comment on lines +31 to +32

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 dangling customer-license row can now suppress a valid live-license transition. Filter dead-link rows before calling matchCustomerLicenseSuccessors; filtering after matching lets their fallback id claim the incoming pool, so its match is discarded and the live pool is left unpropagated.

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/compute/customerLicenseTransitions/pairCustomerLicensesByLicensePlan.ts, line 31:

<comment>A dangling customer-license row can now suppress a valid live-license transition. Filter dead-link rows before calling `matchCustomerLicenseSuccessors`; filtering after matching lets their fallback id claim the incoming pool, so its match is discarded and the live pool is left unpropagated.</comment>

<file context>
@@ -16,60 +17,37 @@ export type CustomerLicensePair = {
-
-	return pairs;
+	const { matches } = matchCustomerLicenseSuccessors({
+		outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
+		incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
+	});
</file context>
Suggested change
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: incomingCustomerProduct.customer_licenses ?? [],
outgoingCustomerLicenses: (
outgoingCustomerProduct.customer_licenses ?? []
).filter((customerLicense) => customerLicense.planLicense),
incomingCustomerLicenses: (
incomingCustomerProduct.customer_licenses ?? []
).filter((customerLicense) => customerLicense.planLicense),

});

return matches.flatMap(
({ outgoingCustomerLicense, incomingCustomerLicense }) => {
const outgoingPlanLicense = outgoingCustomerLicense.planLicense;
const incomingPlanLicense = incomingCustomerLicense.planLicense;
if (!outgoingPlanLicense || !incomingPlanLicense) return [];

return [
{
outgoingCustomerProduct,
incomingCustomerProduct,
outgoingCustomerLicense,
incomingCustomerLicense,
outgoingPlanLicense,
incomingPlanLicense,
},
];
},
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ const referenceKey = (id: string, version?: number) =>
version === undefined ? id : `${id}@${version}`;

const dependenciesForPlan = (plan: CatalogPlanParams) =>
(plan.licenses ?? []).map((license) => ({
id: license.license_plan_id,
version: license.version,
}));
(plan.licenses ?? []).map((license) => license.license_plan_id);

export const sortCatalogPlansByDependencies = (
plans: CatalogPlanParams[],
Expand Down Expand Up @@ -37,9 +34,7 @@ export const sortCatalogPlansByDependencies = (
if (visited.has(key)) return;
visiting.add(key);
for (const reference of dependenciesForPlan(plan)) {
const dependency =
byReference.get(referenceKey(reference.id, reference.version)) ??
byReference.get(reference.id);
const dependency = byReference.get(reference);
if (dependency && dependency !== plan) visit(dependency);
}
visiting.delete(key);
Expand Down
Loading
Loading