Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -6,6 +6,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions";
import { cusProductToExistingBalanceCarryOvers } from "@/internal/billing/v2/utils/handleCarryOvers/cusProductToExistingBalanceCarryOvers";
import { cusProductToOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/cusProductToOneOffPrepaidCarryOvers";
Expand Down Expand Up @@ -62,6 +63,14 @@ export const computeAttachPlan = ({
: [];
const customerLicenseTransitions =
planTiming === "immediate" ? computedCustomerLicenseTransitions : [];
const customerLicenseReleases =
planTiming === "immediate" && currentCustomerProduct
? computeCustomerLicenseReleases({
outgoingCustomerProduct: currentCustomerProduct,
incomingCustomerProduct: newCustomerProduct,
releasedAt: attachBillingContext.currentEpochMs,
})
: {};

const {
entitlements: carriedOverEntitlements,
Expand Down Expand Up @@ -133,6 +142,8 @@ export const computeAttachPlan = ({
customFreeTrial: trialContext?.customFreeTrial,
insertPlanLicenses: attachBillingContext.insertPlanLicenses,
customerLicenseTransitions,
releaseCustomerLicenseAssignments:
customerLicenseReleases.releaseCustomerLicenseAssignments,
lineItems,
insertCustomerEntitlements: [
...(carriedOverCustomerEntitlements ?? []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ 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. */
/** Blocks ambiguous active assignment mappings; dropped pools release in compute. */
export const handleDroppedLicenseErrors = ({
billingContext,
}: {
Expand All @@ -28,20 +26,18 @@ export const handleDroppedLicenseErrors = ({
});

for (const { outgoingCustomerLicense, reason, group } of unmatched) {
if (reason === "dropped") continue;
const used = customerLicenseToUsage({
customerLicense: outgoingCustomerLicense,
});
if (used === 0) 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 ${licensePlanId}, but ${conflict}. Release licenses first.`,
`${used} assigned for ${licensePlanId}, but the licenses in group "${group}" ` +
"are not a 1:1 match on the incoming plan. Release licenses first.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
type AutumnBillingPlan,
type CustomerLicenseUpdate,
CusProductStatus,
type CustomerLicenseUpdate,
type Entitlement,
type FullCusProduct,
type InsertPlanLicenseSpec,
Expand All @@ -10,6 +10,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeCustomerLicenseQuantityChanges } from "@/internal/billing/v2/compute/computeCustomerLicenseQuantityChanges";
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
import { resolveSyncExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/resolveSyncExistingUsagesConfig";
import { initImmediateSyncCustomerProduct } from "./initImmediateSyncCustomerProduct";

Expand All @@ -24,6 +25,9 @@ export type ImmediatePhaseResult = {
customEntitlements: Entitlement[];
insertPlanLicenses: InsertPlanLicenseSpec[];
customerLicenseUpdates: CustomerLicenseUpdate[];
releaseCustomerLicenseAssignments?: NonNullable<
AutumnBillingPlan["releaseCustomerLicenseAssignments"]
>;
};

const expireCustomerProduct = ({
Expand Down Expand Up @@ -81,6 +85,9 @@ export const computeSyncImmediatePhase = ({
const customEntitlements: Entitlement[] = [];
const insertPlanLicenses: InsertPlanLicenseSpec[] = [];
const customerLicenseUpdates: CustomerLicenseUpdate[] = [];
const droppedCustomerLicensePools: NonNullable<
AutumnBillingPlan["releaseCustomerLicenseAssignments"]
>["customerLicensePools"] = [];

for (const productContext of immediatePhase.productContexts) {
const currentCustomerProduct = productContext.currentCustomerProduct;
Expand Down Expand Up @@ -121,6 +128,14 @@ export const computeSyncImmediatePhase = ({
insertPlanLicenses.push(...(productContext.insertPlanLicenses ?? []));

if (productContext.currentCustomerProduct) {
const release = computeCustomerLicenseReleases({
outgoingCustomerProduct: productContext.currentCustomerProduct,
incomingCustomerProduct: insertedCustomerProduct,
releasedAt: currentEpochMs,
}).releaseCustomerLicenseAssignments;
droppedCustomerLicensePools.push(
...(release?.customerLicensePools ?? []),
);
updateCustomerProducts.push(
expireCustomerProduct({
customerProduct: productContext.currentCustomerProduct,
Expand All @@ -137,5 +152,12 @@ export const computeSyncImmediatePhase = ({
customEntitlements,
insertPlanLicenses,
customerLicenseUpdates,
releaseCustomerLicenseAssignments: droppedCustomerLicensePools.length
? {
internalCustomerId: fullCustomer.internal_id,
customerLicensePools: droppedCustomerLicensePools,
releasedAt: currentEpochMs,
}
: undefined,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export const computeSyncPlan = ({
immediate.customerLicenseUpdates.length > 0
? immediate.customerLicenseUpdates
: undefined,
releaseCustomerLicenseAssignments:
immediate.releaseCustomerLicenseAssignments,
lockCustomerCurrency: syncContextToCurrencyLock({ syncContext }),
upsertSubscription,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { AutumnBillingPlan, FullCusProduct } from "@autumn/shared";
import { matchCustomerLicenseSuccessors } from "./matchCustomerLicenseSuccessors.js";

export const computeCustomerLicenseReleases = ({
outgoingCustomerProduct,
incomingCustomerProduct,
releasedAt,
}: {
outgoingCustomerProduct: FullCusProduct;
incomingCustomerProduct?: FullCusProduct;
releasedAt: number;
}): Pick<AutumnBillingPlan, "releaseCustomerLicenseAssignments"> => {
const { unmatched } = matchCustomerLicenseSuccessors({
outgoingCustomerLicenses: outgoingCustomerProduct.customer_licenses ?? [],
incomingCustomerLicenses: incomingCustomerProduct?.customer_licenses ?? [],
});
const customerLicensePools = unmatched.flatMap(
({ outgoingCustomerLicense, reason }) =>
reason === "dropped"
? [
{
id: outgoingCustomerLicense.id,
linkId: outgoingCustomerLicense.link_id,
},
]
: [],
);

return {
releaseCustomerLicenseAssignments: customerLicensePools.length
? {
internalCustomerId: outgoingCustomerProduct.internal_customer_id,
customerLicensePools,
releasedAt,
}
: undefined,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { licenseAssignmentRepo } from "@/internal/licenses/repos/licenseAssignmentRepo";

export const executeCustomerLicenseAssignmentReleases = async ({
ctx,
release,
}: {
ctx: AutumnContext;
release: AutumnBillingPlan["releaseCustomerLicenseAssignments"];
}) => {
if (!release) return;
await licenseAssignmentRepo.releaseActiveAssignments({
db: ctx.db,
...release,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { EntityService } from "@/internal/api/entities/EntityService";
import { executeAutoTopupRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance";
import { executeCustomerLicenseAssignmentReleases } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseAssignmentReleases";
import { executeCustomerLicenseTransitions } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseTransitions";
import { executeCustomerLicenseUpdates } from "@/internal/billing/v2/execute/executeAutumnActions/executeCustomerLicenseUpdates";
import { executeInsertPlanLicenses } from "@/internal/billing/v2/execute/executeAutumnActions/executeInsertPlanLicenses";
Expand Down Expand Up @@ -117,6 +118,10 @@ export const executeAutumnBillingPlan = async ({
ctx,
customerLicenseTransitions: autumnBillingPlan.customerLicenseTransitions,
});
await executeCustomerLicenseAssignmentReleases({
ctx,
release: autumnBillingPlan.releaseCustomerLicenseAssignments,
});

await replaceScheduledPhaseCustomerProductIds({
ctx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ export const mergeAutumnBillingPlans = ({
upsertSubscription: incoming.upsertSubscription ?? base.upsertSubscription,
upsertInvoice: incoming.upsertInvoice ?? base.upsertInvoice,
refundPlan: incoming.refundPlan ?? base.refundPlan,
releaseCustomerLicenseAssignments:
base.releaseCustomerLicenseAssignments ||
incoming.releaseCustomerLicenseAssignments
? {
internalCustomerId: (incoming.releaseCustomerLicenseAssignments ??
base.releaseCustomerLicenseAssignments)!.internalCustomerId,
customerLicensePools:
mergeByKey({
base: base.releaseCustomerLicenseAssignments
?.customerLicensePools,
incoming:
incoming.releaseCustomerLicenseAssignments
?.customerLicensePools,
getKey: (pool) => pool.id,
}) ?? [],
releasedAt: Math.max(
base.releaseCustomerLicenseAssignments?.releasedAt ?? 0,
incoming.releaseCustomerLicenseAssignments?.releasedAt ?? 0,
),
}
: undefined,
});

const mergeById = <T extends { id: string }>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type FullProduct,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct";
import { productActions } from "@/internal/products/actions";
Expand Down Expand Up @@ -67,13 +69,25 @@ export const activateFreeDefaultProduct = async ({
},
},
});
const customerLicenseTransitions = computeCustomerLicenseTransitions({
outgoingCustomerProducts: [customerProduct],
incomingCustomerProducts: [newCustomerProduct],
});
const customerLicenseReleases = computeCustomerLicenseReleases({
outgoingCustomerProduct: customerProduct,
incomingCustomerProduct: newCustomerProduct,
releasedAt: Date.now(),
});

// 3. Execute autumn billing plan
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: {
customerId: fullCustomer?.id ?? "",
insertCustomerProducts: [newCustomerProduct],
customerLicenseTransitions,
releaseCustomerLicenseAssignments:
customerLicenseReleases.releaseCustomerLicenseAssignments,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases.js";
import { computeCustomerLicenseTransitions } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseTransitions.js";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
import { findTransitionSourceCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/findTransitionSourceCustomerProduct";
Expand Down Expand Up @@ -65,6 +66,13 @@ export const activateScheduledCustomerProduct = async ({
incomingCustomerProducts: [customerProduct],
})
: [];
const customerLicenseReleases = transitionSource
? computeCustomerLicenseReleases({
outgoingCustomerProduct: transitionSource,
incomingCustomerProduct: customerProduct,
releasedAt: Date.now(),
})
: {};

// Executing through the shared plan runs the license lifecycle for
// activations that bring license-bearing parents live.
Expand All @@ -80,6 +88,8 @@ export const activateScheduledCustomerProduct = async ({
},
],
customerLicenseTransitions,
releaseCustomerLicenseAssignments:
customerLicenseReleases.releaseCustomerLicenseAssignments,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
import { computeCustomerLicenseReleases } from "@/internal/billing/v2/compute/customerLicenseTransitions/computeCustomerLicenseReleases";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
import { activateFreeSuccessorProduct } from "@/internal/customers/cusProducts/actions/activateFreeSuccessorProduct";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";

/**
* Expires a customer product and activates the default product if needed.
Expand Down Expand Up @@ -39,16 +39,14 @@ export const expireCustomerProductAndActivateDefault = async ({
activatedCustomerProduct?: FullCusProduct;
insertedCustomerProduct?: FullCusProduct;
}> => {
const { db, org, env } = ctx;
const { org, env } = ctx;

// 1. Expire the product
const updates: Partial<InsertCustomerProduct> = {
status: CusProductStatus.Expired,
...extraUpdates,
};

// Executing through the shared plan runs the license lifecycle when the
// expiring product carried license state.
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: {
Expand Down Expand Up @@ -93,5 +91,19 @@ export const expireCustomerProductAndActivateDefault = async ({
fullCustomer,
});

if (!activatedCustomerProduct && !insertedCustomerProduct) {
Comment thread
charlietlamb marked this conversation as resolved.
Outdated
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: {
customerId: fullCustomer.id || fullCustomer.internal_id,
insertCustomerProducts: [],
...computeCustomerLicenseReleases({
outgoingCustomerProduct: customerProduct,
releasedAt: Date.now(),
}),
},
});
}

return { updates, activatedCustomerProduct, insertedCustomerProduct };
};
Loading
Loading