fix: default at the entity level, no_billing_changes flag in attach

This commit is contained in:
John Yeo
2026-03-05 19:03:53 +00:00
parent 8144bc891a
commit 3d73d0eccb
49 changed files with 1639 additions and 166 deletions

View File

@@ -21,8 +21,8 @@ const main = async () => {
logger,
};
await Promise.all([
runProductCron({ ctx }),
runResetCron({ ctx }),
runProductCron(),
runInvoiceCron({ ctx }),
runOneOffCleanup({ ctx }),
]);

View File

@@ -0,0 +1,110 @@
import {
ACTIVE_STATUSES,
type AppEnv,
customerPrices,
customerProducts,
customers,
} from "@autumn/shared";
import {
and,
eq,
type InferSelectModel,
inArray,
isNotNull,
lt,
notExists,
sql,
} from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { generateId } from "@/utils/genUtils";
import { createWorkerAutumnContext } from "@/utils/workerUtils/createAutumnContext";
import type { CronContext } from "../utils/CronContext";
export type ExpiredTrialRow = {
customerProduct: InferSelectModel<typeof customerProducts>;
customer: InferSelectModel<typeof customers>;
};
export type OrgEnvExpiredTrials = {
ctx: AutumnContext;
rows: ExpiredTrialRow[];
};
export const fetchExpiredTrialProducts = async ({
batchSize,
db,
}: {
batchSize: number;
db: DrizzleCli;
}) => {
return db
.select({
customerProduct: customerProducts,
customer: customers,
})
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
notExists(
db
.select()
.from(customerPrices)
.where(eq(customerPrices.customer_product_id, customerProducts.id)),
),
inArray(customerProducts.status, ACTIVE_STATUSES),
isNotNull(customerProducts.trial_ends_at),
lt(
customerProducts.trial_ends_at,
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
),
),
)
.limit(batchSize);
};
export const groupByOrgEnv = async ({
results,
cronContext,
}: {
results: ExpiredTrialRow[];
cronContext: CronContext;
}): Promise<OrgEnvExpiredTrials[]> => {
const byOrgEnv = new Map<
string,
{ orgId: string; env: AppEnv; rows: ExpiredTrialRow[] }
>();
for (const row of results) {
const key = `${row.customer.org_id}:${row.customer.env}`;
const existing = byOrgEnv.get(key);
if (existing) {
existing.rows.push(row);
} else {
byOrgEnv.set(key, {
orgId: row.customer.org_id,
env: row.customer.env as AppEnv,
rows: [row],
});
}
}
const groups: OrgEnvExpiredTrials[] = [];
for (const { orgId, env, rows } of byOrgEnv.values()) {
const ctx = await createWorkerAutumnContext({
db: cronContext.db,
orgId,
env,
logger: cronContext.logger,
workerId: generateId("product-cron"),
});
groups.push({ ctx, rows });
}
return groups;
};

View File

@@ -0,0 +1,66 @@
import {
CusProductStatus,
type customerProducts,
type customers,
type FullProduct,
} from "@autumn/shared";
import { customerProductToDefaultProduct } from "@utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct";
import type { InferSelectModel } from "drizzle-orm";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService";
import { activateFreeDefaultProduct } from "@/internal/customers/cusProducts/actions/activateFreeDefaultProduct";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
export const processExpiredTrialRow = async ({
ctx,
customerProduct,
customer,
defaultProducts,
}: {
ctx: AutumnContext;
customerProduct: InferSelectModel<typeof customerProducts>;
customer: InferSelectModel<typeof customers>;
defaultProducts: FullProduct[];
}) => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customer.internal_id,
withEntities: true,
withSubs: true,
});
const fullCustomerProduct = fullCustomer.customer_products.find(
(cp) => cp.id === customerProduct.id,
);
if (!fullCustomerProduct) return;
const defaultProduct = customerProductToDefaultProduct({
ctx,
customerProduct: fullCustomerProduct,
defaultProducts,
});
if (defaultProduct) {
await activateFreeDefaultProduct({
ctx,
customerProduct: fullCustomerProduct,
fullCustomer,
defaultProduct,
});
}
await CusProductService.update({
ctx,
cusProductId: fullCustomerProduct.id,
updates: {
status: CusProductStatus.Expired,
},
});
await deleteCachedFullCustomer({
ctx,
customerId: fullCustomer.id ?? "",
source: "productCron",
});
};

View File

@@ -1,84 +1,96 @@
import { type AppEnv, CusProductStatus, ms } from "@autumn/shared";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { batchDeleteCachedFullCustomers } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers";
import { ProductService } from "@/internal/products/ProductService";
import type { CronContext } from "../utils/CronContext";
import {
ACTIVE_STATUSES,
CusProductStatus,
customerPrices,
customerProducts,
customers,
notNullish,
} from "@autumn/shared";
import { and, eq, inArray, isNotNull, lt, notExists, sql } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers";
fetchExpiredTrialProducts,
groupByOrgEnv,
} from "./fetchExpiredTrialProducts";
import { processExpiredTrialRow } from "./processExpiredTrialRow";
export const runProductCron = async () => {
export const runProductCron = async ({
ctx: cronContext,
}: {
ctx: CronContext;
}) => {
console.log("Running product cron");
const { db } = cronContext;
const maxIterations = 10;
const timeoutMs = ms.minutes(1);
const startTime = Date.now();
const batchSize = 1000;
let totalExpired = 0;
try {
// Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now
const results = await db
.select()
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
// No customer_prices exist for this customer_product
notExists(
db
.select()
.from(customerPrices)
.where(
eq(customerPrices.customer_product_id, customerProducts.id),
),
),
// status is not expired
inArray(customerProducts.status, ACTIVE_STATUSES),
let iteration = 0;
// trial_ends_at is not null
isNotNull(customerProducts.trial_ends_at),
while (iteration < maxIterations && Date.now() - startTime < timeoutMs) {
iteration++;
// is already expired
lt(
customerProducts.trial_ends_at,
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
),
),
);
const results = await fetchExpiredTrialProducts({ batchSize, db });
console.log(
`Found ${results.length} customer products with no prices and active trials`,
);
if (results.length === 0) break;
const expireCusProducts = async (ids: string[]) => {
await db
.update(customerProducts)
.set({
status: CusProductStatus.Expired,
})
.where(inArray(customerProducts.id, ids));
};
const batchSize = 250;
for (let i = 0; i < results.length; i += batchSize) {
const batch = results.slice(i, i + batchSize);
await expireCusProducts(batch.map((r) => r.customer_products.id));
console.log(
`Expired batch of ${i + batch.length}/${results.length} customer products`,
`Product cron iteration ${iteration}: processing ${results.length} expired trials`,
);
await batchDeleteCachedCustomers({
customers: batch
.filter((r) => notNullish(r.customers.id))
.map((r) => ({
orgId: r.customers.org_id,
env: r.customers.env,
customerId: r.customers.id!,
})),
});
const resultsByOrgEnv = await groupByOrgEnv({ results, cronContext });
for (const { ctx, rows } of resultsByOrgEnv) {
const defaultProducts = await ProductService.listDefault({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
onlyFree: true,
});
if (defaultProducts.length === 0) {
await customerProductRepo.batchUpdate({
ctx,
updates: rows.map((row) => ({
id: row.customerProduct.id,
updates: {
status: CusProductStatus.Expired,
},
})),
});
await batchDeleteCachedFullCustomers({
customers: rows.map((row) => ({
orgId: row.customer.org_id,
env: row.customer.env as AppEnv,
customerId: row.customer.id ?? "",
})),
});
console.log(`Expired ${rows.length} customer products`);
continue;
}
const processBatchSize = 250;
for (let i = 0; i < rows.length; i += processBatchSize) {
const batch = rows.slice(i, i + processBatchSize);
await Promise.all(
batch.map((row) =>
processExpiredTrialRow({
ctx,
customerProduct: row.customerProduct,
customer: row.customer,
defaultProducts,
}),
),
);
}
}
totalExpired += results.length;
console.log(`Expired ${totalExpired} customer products so far`);
if (results.length < batchSize) break;
}
return results;
console.log(`Product cron finished: expired ${totalExpired} total`);
} catch (error) {
console.log("Error running product cron:", error);
}

View File

@@ -83,8 +83,9 @@ export const processConsumablePricesForInvoiceCreated = async ({
}),
});
if (lineItems.length > 0) {
const invoiceItems = await createStripeInvoiceItems({
const skipOverageSubmission = ctx.org.config.skip_overage_submission;
if (lineItems.length > 0 && !skipOverageSubmission) {
await createStripeInvoiceItems({
ctx,
invoiceItems: lineItemsToCreateInvoiceItemsParams({
stripeCustomerId: eventContext.stripeCustomer.id,

View File

@@ -82,7 +82,8 @@ export const processConsumablePricesForSubscriptionDeleted = async ({
// No cusEntFilter - bill all consumable entitlements on cancellation
});
if (lineItems.length > 0) {
const skipOverageSubmission = ctx.org.config.skip_overage_submission;
if (lineItems.length > 0 && !skipOverageSubmission) {
// 2. Create, finalize, and pay a single invoice with all line items
const invoiceLines = lineItemsToInvoiceAddLinesParams({ lineItems });

View File

@@ -1,4 +1,8 @@
import type { FullCusProduct } from "@autumn/shared";
import {
customerProductEligibleForDefaultProduct,
enrichFullCustomerWithEntity,
type FullCusProduct,
} from "@autumn/shared";
import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { scheduleDefaultProduct } from "@/internal/customers/cusProducts/cusProductUtils/scheduleDefaultProduct";
@@ -31,12 +35,19 @@ export const scheduleDefaultProducts = async ({
// Schedule default for each canceled non-entity product group
for (const canceledProduct of canceledCustomerProducts) {
if (canceledProduct.internal_entity_id) continue;
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
ctx,
customerProduct: canceledProduct,
});
if (!eligibleForDefaultProduct) continue;
await scheduleDefaultProduct({
ctx,
productGroup: canceledProduct.product.group,
fullCustomer,
fullCustomer: enrichFullCustomerWithEntity({
fullCustomer,
internalEntityId: canceledProduct.internal_entity_id ?? null,
}),
scheduleAtMs,
defaultProducts,
});

View File

@@ -116,8 +116,8 @@ export const setupAttachBillingContext = async ({
product: attachProduct,
targetCustomerProduct: currentCustomerProduct,
contextOverride,
paramDiscounts: params.discounts,
newBillingSubscription: shouldForceNewSubscription || undefined,
params,
// paramDiscounts: params.discounts,
});
const featureQuantities = setupFeatureQuantitiesContext({
@@ -229,5 +229,9 @@ export const setupAttachBillingContext = async ({
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
externalId: params.subscription_id,
skipBillingChanges:
params.no_billing_changes ??
params.processor_subscription_id !== undefined,
};
};

View File

@@ -92,8 +92,9 @@ export const setupMultiAttachBillingContext = async ({
ctx,
fullCustomer,
targetCustomerProduct: undefined,
paramDiscounts: params.discounts,
newBillingSubscription: params.new_billing_subscription || undefined,
params,
// paramDiscounts: params.discounts,
// newBillingSubscription: params.new_billing_subscription || undefined,
});
const invoiceMode = setupInvoiceModeContext({

View File

@@ -1,6 +1,10 @@
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
import {
CusProductStatus,
enrichFullCustomerWithEntity,
type FullCusProduct,
type UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
/**
@@ -44,7 +48,10 @@ export const computeDefaultCustomerProduct = ({
const newDefaultProduct = initFullCustomerProduct({
ctx,
initContext: {
fullCustomer,
fullCustomer: enrichFullCustomerWithEntity({
fullCustomer,
internalEntityId: customerProduct.internal_entity_id ?? null,
}),
fullProduct: defaultProduct,
featureQuantities: [],
resetCycleAnchor: startsAt,

View File

@@ -0,0 +1,29 @@
import type {
AutumnBillingPlan,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
type CusProductFieldUpdates = NonNullable<
NonNullable<AutumnBillingPlan["updateCustomerProduct"]>["updates"]
>;
export const computeFieldUpdates = ({
params,
}: {
params: UpdateSubscriptionV1Params;
}) => {
const updates: CusProductFieldUpdates = {};
if (params.processor_subscription_id !== undefined) {
// unsets processor subscription id if it is set to a new value
updates.subscription_ids = params.processor_subscription_id
? [params.processor_subscription_id]
: [];
}
if (params.status !== undefined) {
updates.status = params.status;
}
return updates;
};

View File

@@ -14,6 +14,7 @@ import {
import { computeCustomPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan";
import { finalizeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan";
import { computeUpdateQuantityPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan";
import { computeFieldUpdates } from "./computeFieldUpdates";
/**
* Compute the subscription update plan
@@ -40,6 +41,7 @@ export const computeUpdateSubscriptionPlan = async ({
case UpdateSubscriptionIntent.UpdatePlan:
plan = await computeCustomPlan({
ctx,
params,
updateSubscriptionContext: billingContext,
});
break;
@@ -61,6 +63,17 @@ export const computeUpdateSubscriptionPlan = async ({
break;
}
const fieldUpdates = computeFieldUpdates({ params });
if (Object.keys(fieldUpdates).length > 0) {
plan.updateCustomerProduct = {
customerProduct: billingContext.customerProduct,
updates: {
...plan.updateCustomerProduct?.updates,
...fieldUpdates,
},
};
}
// Apply cancel plan if cancelAction is set in context
plan = computeCancelPlan({ ctx, billingContext, plan });

View File

@@ -1,6 +1,7 @@
import type {
AutumnBillingPlan,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
@@ -10,9 +11,11 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum
export const computeCustomPlan = async ({
ctx,
params,
updateSubscriptionContext,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV1Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
}) => {
const {
@@ -28,6 +31,7 @@ export const computeCustomPlan = async ({
// Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx,
params,
updateSubscriptionContext,
fullProduct: customFullProduct,
currentCustomerProduct: customerProduct,

View File

@@ -2,6 +2,7 @@ import type {
FullCusProduct,
FullProduct,
UpdateSubscriptionBillingContext,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeCancelFields } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelFields";
@@ -9,11 +10,13 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus
export const computeCustomPlanNewCustomerProduct = ({
ctx,
params,
updateSubscriptionContext,
fullProduct,
currentCustomerProduct,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV1Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
fullProduct: FullProduct;
currentCustomerProduct: FullCusProduct;
@@ -78,6 +81,12 @@ export const computeCustomPlanNewCustomerProduct = ({
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
startsAt: currentCustomerProduct.starts_at ?? undefined,
...cancelFields,
...(params.processor_subscription_id
? { subscriptionId: params.processor_subscription_id }
: {}),
...(params.status ? { status: params.status } : {}),
},
});

View File

@@ -1,6 +1,6 @@
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import { formatMs } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
export const logUpdateSubscriptionContext = ({
@@ -55,6 +55,7 @@ export const logUpdateSubscriptionContext = ({
defaultProduct: billingContext.defaultProduct?.name ?? "undefined",
cancelAction: cancelAction ? cancelAction : "no cancel operation",
skipBillingChanges: billingContext.skipBillingChanges,
},
},
});

View File

@@ -1,5 +1,5 @@
import {
cp,
customerProductEligibleForDefaultProduct,
type FullCusProduct,
type FullProduct,
nullish,
@@ -25,12 +25,18 @@ export const setupDefaultProductContext = async ({
if (nullish(params.cancel_action)) return undefined;
// Add-ons don't trigger default products
const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
.main()
.recurring()
.customerScoped();
const valid = customerProductEligibleForDefaultProduct({
ctx,
customerProduct,
});
if (!isMainCustomerScopedAndRecurring) return undefined;
if (!valid) return undefined;
// const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
// .main()
// .recurring()
// .customerScoped();
// if (!isMainCustomerScopedAndRecurring) return undefined;
const defaultProduct = await getFreeDefaultProductByGroup({
ctx,

View File

@@ -17,6 +17,13 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
const FIELDS_WITH_BILLING_CHANGES = [
"feature_quantities",
"version",
"customize",
"cancel_action",
] as const satisfies (keyof UpdateSubscriptionV1Params)[];
/**
* Fetch the context for updating a subscription
* @param ctx - The context
@@ -109,6 +116,12 @@ export const setupUpdateSubscriptionBillingContext = async ({
const cancelAction = setupCancelAction({ params });
const billingRelatedFields = Object.keys(params).filter((key) =>
FIELDS_WITH_BILLING_CHANGES.includes(
key as (typeof FIELDS_WITH_BILLING_CHANGES)[number],
),
);
return {
fullCustomer,
fullProducts: [fullProduct],
@@ -136,5 +149,10 @@ export const setupUpdateSubscriptionBillingContext = async ({
billingVersion: contextOverride.billingVersion
? contextOverride.billingVersion
: (customerProduct.billing_version ?? BillingVersion.V2),
skipBillingChanges:
params.no_billing_changes === true ||
params.processor_subscription_id !== undefined ||
billingRelatedFields.length === 0,
};
};

View File

@@ -2,6 +2,7 @@ import type {
BillingContext,
BillingPlan,
BillingResult,
StripeBillingPlanResult,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
@@ -18,11 +19,14 @@ export const executeBillingPlan = async ({
billingContext: BillingContext;
billingPlan: BillingPlan;
}): Promise<BillingResult> => {
const stripeBillingResult = await executeStripeBillingPlan({
ctx,
billingPlan,
billingContext,
});
const stripeBillingResult: StripeBillingPlanResult =
billingContext.skipBillingChanges
? {}
: await executeStripeBillingPlan({
ctx,
billingPlan,
billingContext,
});
if (stripeBillingResult.deferred) {
// Store line items even when deferred — invoice already exists in DB

View File

@@ -29,6 +29,10 @@ export const evaluateStripeBillingPlan = async ({
autumnBillingPlan: AutumnBillingPlan;
checkoutMode?: CheckoutMode;
}): Promise<StripeBillingPlan> => {
if (billingContext.skipBillingChanges) {
return {};
}
await initStripeResourcesForBillingPlan({
ctx,
autumnBillingPlan,

View File

@@ -1,8 +1,11 @@
import {
type AttachParamsV1,
type FullCustomer,
getTargetSubscriptionCusProduct,
InternalError,
type MultiAttachParamsV0,
type Product,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { createStripeCli } from "@server/external/connect/createStripeCli";
import type { StripeSubscriptionWithDiscounts } from "@server/external/stripe/subscriptions";
@@ -18,18 +21,29 @@ export const fetchStripeSubscriptionForBilling = async ({
fullCus,
product,
targetCusProductId,
newBillingSubscription,
params,
// newBillingSubscription,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
product?: Product;
targetCusProductId?: string;
newBillingSubscription?: boolean;
// newBillingSubscription?: boolean;
params?: AttachParamsV1 | MultiAttachParamsV0 | UpdateSubscriptionV1Params;
}): Promise<StripeSubscriptionWithDiscounts | undefined> => {
if (newBillingSubscription) {
if (
params &&
"new_billing_subscription" in params &&
params.new_billing_subscription
) {
return undefined;
}
const processorSubscriptionId =
params && "processor_subscription_id" in params
? params.processor_subscription_id
: undefined;
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
@@ -40,7 +54,8 @@ export const fetchStripeSubscriptionForBilling = async ({
cusProductId: targetCusProductId,
});
const subId = cusProductWithSub?.subscription_ids?.[0];
const subId =
processorSubscriptionId ?? cusProductWithSub?.subscription_ids?.[0];
if (!subId) return undefined;
@@ -63,5 +78,11 @@ export const fetchStripeSubscriptionForBilling = async ({
});
}
if (sub.customer !== fullCus.processor.id) {
throw new InternalError({
message: `[Stripe Subscription] Subscription is not for the current customer: ${subId}`,
});
}
return sub as StripeSubscriptionWithDiscounts;
};

View File

@@ -1,9 +1,11 @@
import type {
AttachDiscount,
AttachParamsV1,
BillingContextOverride,
FullCusProduct,
FullCustomer,
MultiAttachParamsV0,
Product,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling";
@@ -17,28 +19,39 @@ export const setupStripeBillingContext = async ({
product,
targetCustomerProduct,
contextOverride = {},
paramDiscounts,
newBillingSubscription,
// paramDiscounts,
params,
// newBillingSubscription,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
product?: Product;
targetCustomerProduct?: FullCusProduct;
contextOverride?: BillingContextOverride;
paramDiscounts?: AttachDiscount[];
newBillingSubscription?: boolean;
// paramDiscounts?: AttachDiscount[];
params?: AttachParamsV1 | MultiAttachParamsV0 | UpdateSubscriptionV1Params;
// newBillingSubscription?: boolean;
}) => {
const { stripeBillingContext } = contextOverride;
if (stripeBillingContext) return stripeBillingContext;
const {
stripeCus: stripeCustomer,
paymentMethod,
testClockFrozenTime,
} = await fetchStripeCustomerForBilling({
ctx,
fullCus: fullCustomer,
});
// If no target customer product, skip subscription/schedule fetching
const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx,
fullCus: fullCustomer,
product,
targetCusProductId: targetCustomerProduct?.id,
newBillingSubscription,
params,
});
const stripeSubscriptionSchedule = targetCustomerProduct
@@ -54,20 +67,12 @@ export const setupStripeBillingContext = async ({
})
: undefined;
const {
stripeCus: stripeCustomer,
paymentMethod,
testClockFrozenTime,
} = await fetchStripeCustomerForBilling({
ctx,
fullCus: fullCustomer,
});
const stripeDiscounts = await fetchStripeDiscountsForBilling({
ctx,
stripeSubscription,
stripeCustomer,
paramDiscounts,
paramDiscounts:
params && "discounts" in params ? params.discounts : undefined,
});
return {

View File

@@ -81,7 +81,8 @@ export const productToInsertParams = ({
cusProducts: fullCus.customer_products,
freeTrial: null,
optionsList: [],
internalEntityId: undefined,
internalEntityId: fullCus.entity?.internal_id ?? undefined,
entityId: fullCus.entity?.id ?? undefined,
entities: entities || [],
replaceables: [],
};

View File

@@ -1,7 +1,9 @@
import {
customerProductEligibleForDefaultProduct,
enrichFullCustomerWithEntity,
type FullCusProduct,
type FullCustomer,
isCustomerProductCustomerScoped,
type FullProduct,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
@@ -12,13 +14,22 @@ export const activateFreeDefaultProduct = async ({
ctx,
customerProduct,
fullCustomer,
defaultProduct,
}: {
ctx: AutumnContext;
customerProduct: FullCusProduct;
fullCustomer: FullCustomer;
defaultProduct?: FullProduct;
}): Promise<FullCusProduct | undefined> => {
const { logger } = ctx;
if (!isCustomerProductCustomerScoped(customerProduct)) {
// customerProduct eligible for default product
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
ctx,
customerProduct,
});
if (!eligibleForDefaultProduct) {
logger.debug(
`[activateFreeDefaultProduct] Skipping - product is not main recurring customer scoped: ${customerProduct.product.name}`,
);
@@ -26,10 +37,12 @@ export const activateFreeDefaultProduct = async ({
}
// 1. Get free default product for group
const freeDefaultProduct = await productActions.getFreeDefaultByGroup({
ctx,
productGroup: customerProduct.product.group,
});
const freeDefaultProduct =
defaultProduct ??
(await productActions.getFreeDefaultByGroup({
ctx,
productGroup: customerProduct.product.group,
}));
if (!freeDefaultProduct) return;
@@ -37,7 +50,10 @@ export const activateFreeDefaultProduct = async ({
const newCustomerProduct = initFullCustomerProductFromProduct({
ctx,
initContext: {
fullCustomer,
fullCustomer: enrichFullCustomerWithEntity({
fullCustomer,
internalEntityId: customerProduct.internal_entity_id ?? null,
}),
fullProduct: freeDefaultProduct,
currentEpochMs: Date.now(),
featureQuantities: [],

View File

@@ -10,6 +10,7 @@ import { getApiEntity } from "../entityUtils/apiEntityUtils/getApiEntity";
import { constructEntity } from "../entityUtils/entityUtils";
import { createEntityForCusProduct } from "../handlers/handleCreateEntity/createEntityForCusProduct";
import { validateAndGetInputEntities } from "../handlers/handleCreateEntity/getInputEntities";
import { attachDefaultProductsToEntities } from "./batchCreateEntities/attachDefaultProductsToEntities";
export const batchCreateEntities = async ({
ctx,
@@ -86,11 +87,16 @@ export const batchCreateEntities = async ({
newEntities.push(...insertedEntities);
await attachDefaultProductsToEntities({
ctx,
fullCustomer: fullCus,
entities: newEntities,
customerData,
});
// Get api entity for each entity...
const apiEntities = [];
for (const entity of newEntities) {
// Cloned fullCus
const clonedFullCus = structuredClone(fullCus);
clonedFullCus.entity = entity;

View File

@@ -0,0 +1,59 @@
import {
type CustomerData,
type Entity,
type FullCustomer,
isFreeProduct,
orgDefaultAppliesToEntities,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { initFullCustomerProductFromProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProductFromProduct";
import { setupDefaultProductsContext } from "@/internal/customers/actions/createWithDefaults/setup/setupDefaultProductsContext";
export const attachDefaultProductsToEntities = async ({
ctx,
fullCustomer,
entities,
customerData,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
entities: Entity[];
customerData?: CustomerData;
}) => {
if (!orgDefaultAppliesToEntities({ ctx })) return;
const defaultProducts = await setupDefaultProductsContext({
ctx,
customerData,
});
const freeDefaultProducts = defaultProducts.fullProducts.filter((product) =>
isFreeProduct({ prices: product.prices }),
);
const currentEpochMs = Date.now();
for (const entity of entities) {
const insertCustomerProducts = freeDefaultProducts.map((product) =>
initFullCustomerProductFromProduct({
ctx,
initContext: {
fullCustomer: {
...fullCustomer,
entity: entity,
},
fullProduct: product,
currentEpochMs,
},
}),
);
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: {
customerId: fullCustomer.id ?? "",
insertCustomerProducts,
},
});
}
};

View File

@@ -28,7 +28,7 @@ import {
import { StatusCodes } from "http-status-codes";
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
import { getLatestProducts } from "./productUtils";
import { getLatestProducts, isFreeProduct } from "./productUtils";
import { sortFullProducts } from "./productUtils/sortProductUtils";
const parseFreeTrials = ({
@@ -131,12 +131,14 @@ export class ProductService {
env,
group,
inIds,
onlyFree = false,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
group?: string;
inIds?: string[];
onlyFree?: boolean;
}) {
const prods = (await db.query.products.findMany({
where: and(
@@ -167,6 +169,12 @@ export class ProductService {
const latestProducts = getLatestProducts(prods);
if (onlyFree) {
return latestProducts.filter((p) =>
isFreeProduct(p.prices),
) as FullProduct[];
}
return latestProducts as FullProduct[];
}

View File

@@ -1,8 +0,0 @@
import type { TestGroup } from "../types";
export const temp: TestGroup = {
name: "temp",
description: "Tests created in this current session",
tier: "domain",
paths: ["auto-topup"],
};

View File

@@ -50,7 +50,6 @@ const allGroups: TestGroup[] = [
webhooks,
advanced,
misc,
temp,
];
export const getAllGroups = (): TestGroup[] => allGroups;

View File

@@ -2,16 +2,37 @@ import type { TestGroup } from "./types";
export const temp: TestGroup = {
name: "temp",
description: "Failed tests from billing V2 run",
description: "Default product behavior verification tests",
tier: "domain",
paths: [
"integration/billing/multi-attach/customize/multi-attach-customize-addons.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts",
"integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts",
"integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted-invoice-discounts.test.ts",
"integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable-discounts.test.ts",
"integration/crud/customers/create-customer.test.ts",
"integration/crud/customers/update-customer.test.ts",
"integration/crud/customers/cross-version-list-customers.test.ts",
// Cancel immediately with default tests
"integration/billing/update-subscription/cancel/immediately/cancel-immediately.test.ts",
// Cancel end of cycle tests
"integration/billing/update-subscription/cancel/end-of-cycle/cancel-end-of-cycle.test.ts",
// Uncancel tests
"integration/billing/update-subscription/cancel/uncancel/uncancel-basic.test.ts",
// Create customer with defaults
"integration/crud/customers/create-customer-defaults.test.ts",
// Default applies to entities (the new behavior)
"integration/org-config/default-applies-to-entity.test.ts",
// Stripe webhook: subscription deleted (activates defaults)
"integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts",
// Stripe webhook: subscription updated uncancel
"integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts",
// Scheduled switch basic (pro to free downgrade flows)
"integration/billing/attach/scheduled-switch/scheduled-switch-basic.test.ts",
// Scheduled switch with entities
"integration/billing/attach/scheduled-switch/scheduled-switch-entities-basic.test.ts",
// Invoice created consumable (usage-in-arrear)
"integration/billing/stripe-webhooks/invoice-created/invoice-created-consumable.test.ts",
],
};

View File

@@ -0,0 +1,191 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type AttachParamsV0Input,
secondsToMs,
} from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { createCustomStripeSubscription } from "@tests/integration/billing/utils/stripe/createCustomStripeSubscription";
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
import { CusService } from "@/internal/customers/CusService";
import { expectCustomerInvoiceCorrect } from "../../utils/expectCustomerInvoiceCorrect";
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {
const customerId = "processor-sub-id-anchor";
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [monthlyMessages],
});
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const stripeSubscription = await createCustomStripeSubscription({
ctx,
customerId,
productId: pro.id,
});
const billingCycleAnchorMs = secondsToMs(
stripeSubscription.billing_cycle_anchor,
);
expect(testClockId).toBeDefined();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfWeeks: 2,
});
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
processor_subscription_id: stripeSubscription.id,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfter,
active: [pro.id],
});
const fullCustomerAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProduct = fullCustomerAfter.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(cusProduct).toBeDefined();
expect(cusProduct!.subscription_ids).toContain(stripeSubscription.id);
const messagesResetAt =
customerAfter.features[TestFeature.Messages]?.next_reset_at;
expect(messagesResetAt).toBeDefined();
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
resetsAt: addMonths(billingCycleAnchorMs, 1).getTime(),
});
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
stripeSubscription.id,
);
expect(stripeSubscriptionAfter.status).toEqual("active");
expectStripeSubscriptionUnchanged({
before: stripeSubscription,
after: stripeSubscriptionAfter,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
});
});
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {
const customerId = "processor-sub-id-upgrade";
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [monthlyMessages],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
const stripeSubscription = await createCustomStripeSubscription({
ctx,
customerId,
productId: pro.id,
});
const billingCycleAnchorMs = secondsToMs(
stripeSubscription.billing_cycle_anchor,
);
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
processor_subscription_id: stripeSubscription.id,
});
await expectCustomerProducts({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
active: [pro.id],
});
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: premium.id,
processor_subscription_id: stripeSubscription.id,
no_billing_changes: true,
});
const customerAfterUpgrade =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterUpgrade,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer: customerAfterUpgrade,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
resetsAt: addMonths(billingCycleAnchorMs, 1).getTime(),
});
const stripeSubscriptionAfterUpgrade =
await ctx.stripeCli.subscriptions.retrieve(stripeSubscription.id);
expect(stripeSubscriptionAfterUpgrade.status).toEqual("active");
expectStripeSubscriptionUnchanged({
before: stripeSubscription,
after: stripeSubscriptionAfterUpgrade,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
});
});

View File

@@ -87,7 +87,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel active subscription via Stripe (
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
await timeout(12000);
// Verify pro is gone and free is active
const customerAfterCancel =
@@ -189,7 +189,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel after end_of_cycle via Stripe")}
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
await timeout(12000);
// Verify pro is gone and free is active
const customerAfterCancel =
@@ -284,7 +284,7 @@ test(`${chalk.yellowBright("sub.deleted: cancel with scheduled downgrade via Str
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
// Wait for webhook to process
await timeout(8000);
await timeout(12000);
// Verify premium and pro are gone, free is active
const customerAfterCancel =

View File

@@ -0,0 +1,6 @@
import { test } from "bun:test";
import chalk from "chalk";
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {});
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {});

View File

@@ -0,0 +1,6 @@
import { test } from "bun:test";
import chalk from "chalk";
test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {});
test(`${chalk.yellowBright("processor_subscription_id: upgrade with no_billing_changes preserves anchor and subscription")}`, async () => {});

View File

@@ -0,0 +1,181 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
ApiCustomerV5,
CustomerExpand,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { createCustomStripeSubscription } from "@tests/integration/billing/utils/stripe/createCustomStripeSubscription";
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
// ─── Test 1: Update processor_subscription_id to null clears subscription_ids ───
test(`${chalk.yellowBright("update processor_subscription_id: setting null clears subscription_ids")}`, async () => {
const customerId = "update-proc-sub-id-null";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, autumnV2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
processor_subscription_id: null,
});
const fullCustomerAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProductAfter = fullCustomerAfter.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(cusProductAfter).toBeDefined();
expect(cusProductAfter?.subscription_ids).toEqual([]);
});
// ─── Test 2: Update processor_subscription_id to a new stripe subscription ───
test(`${chalk.yellowBright("update processor_subscription_id: set to new stripe subscription links correctly")}`, async () => {
const customerId = "update-proc-sub-id-set";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, autumnV2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const newStripeSubscription = await createCustomStripeSubscription({
ctx,
customerId,
productId: pro.id,
});
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
processor_subscription_id: newStripeSubscription.id,
});
const fullCustomerAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProductAfter = fullCustomerAfter.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(cusProductAfter).toBeDefined();
expect(cusProductAfter?.subscription_ids).toContain(newStripeSubscription.id);
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({ customer: customerAfter, active: [pro.id] });
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 100,
});
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
newStripeSubscription.id,
);
expectStripeSubscriptionUnchanged({
before: newStripeSubscription,
after: stripeSubscriptionAfter,
});
});
// ─── Test 3: Update processor_subscription_id + customize simultaneously ───
// Stripe subscription should remain unchanged, only Autumn plan is updated.
test(`${chalk.yellowBright("update processor_subscription_id: with customize leaves stripe subscription unchanged")}`, async () => {
const customerId = "update-proc-sub-id-customize";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, autumnV2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const newStripeSubscription = await createCustomStripeSubscription({
ctx,
customerId,
productId: pro.id,
});
await autumnV2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
processor_subscription_id: newStripeSubscription.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 50 }),
items: [itemsV2.monthlyMessages({ included: 250 })],
},
});
const fullCustomerAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProductAfter = fullCustomerAfter.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(cusProductAfter).toBeDefined();
expect(cusProductAfter?.subscription_ids).toContain(newStripeSubscription.id);
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({ customer: customerAfter, active: [pro.id] });
const customerV2After = await autumnV2.customers.get<ApiCustomerV5>(
customerId,
{
expand: ["subscriptions.plan" as CustomerExpand],
},
);
expect(customerV2After.subscriptions[0].plan?.price?.amount).toBe(50);
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 250,
});
const stripeSubscriptionAfter = await ctx.stripeCli.subscriptions.retrieve(
newStripeSubscription.id,
);
expectStripeSubscriptionUnchanged({
before: newStripeSubscription,
after: stripeSubscriptionAfter,
});
});

View File

@@ -0,0 +1,49 @@
import type Stripe from "stripe";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
/**
* Creates a Stripe subscription for a customer using an inline price
* derived from the Autumn product's Stripe processor ID.
*/
export const createCustomStripeSubscription = async ({
ctx,
customerId,
productId,
unitAmount = 2000,
interval = "month",
}: {
ctx: TestContext;
customerId: string;
productId: string;
unitAmount?: number;
interval?: Stripe.PriceCreateParams.Recurring.Interval;
}): Promise<Stripe.Subscription> => {
const [fullCustomer, fullProduct] = await Promise.all([
CusService.getFull({ ctx, idOrInternalId: customerId }),
ProductService.getFull({
db: ctx.db,
idOrInternalId: productId,
orgId: ctx.org.id,
env: ctx.env,
}),
]);
const stripeCustomerId = fullCustomer.processor.id ?? "";
const stripeProductId = fullProduct.processor?.id ?? "";
return ctx.stripeCli.subscriptions.create({
customer: stripeCustomerId,
items: [
{
price_data: {
currency: "usd",
product: stripeProductId,
unit_amount: unitAmount,
recurring: { interval },
},
},
],
});
};

View File

@@ -0,0 +1,38 @@
import { expect } from "bun:test";
import type Stripe from "stripe";
/**
* Asserts that two Stripe subscriptions are equivalent --
* same items (price IDs + quantities) and billing_cycle_anchor.
*/
export const expectStripeSubscriptionUnchanged = ({
before,
after,
}: {
before: Stripe.Subscription;
after: Stripe.Subscription;
}) => {
expect(
after.billing_cycle_anchor,
"billing_cycle_anchor should be unchanged",
).toEqual(before.billing_cycle_anchor);
expect(
after.items.data.length,
"subscription should have the same number of items",
).toEqual(before.items.data.length);
for (const beforeItem of before.items.data) {
const afterItem = after.items.data.find(
(i) => i.price.id === beforeItem.price.id,
);
expect(
afterItem,
`subscription item with price ${beforeItem.price.id} should still exist`,
).toBeDefined();
expect(
afterItem!.quantity,
`quantity for price ${beforeItem.price.id} should be unchanged`,
).toEqual(beforeItem.quantity);
}
};

View File

@@ -0,0 +1,306 @@
/**
* Tests for default_applies_to_entities org config behavior.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomerV3,
customerProducts,
FreeTrialDuration,
} from "@autumn/shared";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { getEntitySubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import defaultCtx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { runProductCron } from "@/cron/productCron/runProductCron";
import { db } from "@/db/initDrizzle";
import { logger } from "@/external/logtail/logtailUtils";
import { CusService } from "@/internal/customers/CusService";
import { OrgService } from "@/internal/orgs/OrgService";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Default applies to entities
// ═══════════════════════════════════════════════════════════════════════════════
describe("default applies to entities", () => {
beforeAll(async () => {
await OrgService.update({
db: db,
orgId: defaultCtx.org.id,
updates: {
config: {
...defaultCtx.org.config,
default_applies_to_entities: true,
},
},
});
});
afterAll(async () => {
await OrgService.update({
db: db,
orgId: defaultCtx.org.id,
updates: {
config: {
...defaultCtx.org.config,
default_applies_to_entities: false,
},
},
});
});
test.concurrent(`${chalk.yellowBright("default applies to entities 1")}`, async () => {
const customerId = "default-applies-to-entities";
const consumableMessagesItem = items.monthlyMessages({
includedUsage: 100,
});
const free = products.base({
id: "free",
isDefault: true,
items: [consumableMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
notPresent: [free.id],
});
await autumnV1.entities.create(customerId, {
id: "user1",
name: "User 1",
feature_id: TestFeature.Users,
customer_data: {
internal_options: {
default_group: customerId,
},
},
});
const entity = await autumnV1.entities.get(customerId, "user1");
await expectCustomerProducts({
customer: entity,
active: [free.id],
});
});
test(`${chalk.yellowBright("default applies to entities 2: product cron expires trial and activates default")}`, async () => {
const customerId = "cron-default-entity";
const monthlyMessages = items.monthlyMessages({
includedUsage: 100,
});
const free = products.base({
id: "free",
isDefault: true,
items: [monthlyMessages],
});
const proTrial = products.base({
id: "pro-trial",
items: [monthlyMessages],
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day,
},
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, proTrial] }),
s.entities({
count: 1,
featureId: TestFeature.Users,
defaultGroup: customerId,
}),
],
actions: [s.attach({ productId: proTrial.id, entityIndex: 0 })],
});
const entityBefore = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entityBefore,
active: [proTrial.id],
});
const fullCustomer = await CusService.getFull({
ctx: defaultCtx,
idOrInternalId: customerId,
withEntities: true,
});
const trialCusProduct = fullCustomer.customer_products.find(
(cp) => cp.product_id === proTrial.id && cp.entity_id === entities[0].id,
);
expect(trialCusProduct).toBeDefined();
const pastTrialEnd = Date.now() - 60_000;
await db
.update(customerProducts)
.set({ trial_ends_at: pastTrialEnd })
.where(eq(customerProducts.id, trialCusProduct!.id));
await runProductCron({ ctx: { db, logger } });
const entityAfter = await autumnV1.entities.get(customerId, entities[0].id);
await expectCustomerProducts({
customer: entityAfter,
active: [free.id],
notPresent: [proTrial.id],
});
});
test(`${chalk.yellowBright("default applies to entities 3: cancel end of cycle schedules free on entity")}`, async () => {
const customerId = "cancel-eoc-entity-default";
const monthlyMessages = items.monthlyMessages({
includedUsage: 100,
});
const free = products.base({
id: "free",
isDefault: true,
items: [monthlyMessages],
});
const pro = products.pro({
id: "pro",
items: [monthlyMessages],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
s.entities({
count: 2,
featureId: TestFeature.Users,
defaultGroup: customerId,
}),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: pro.id, entityIndex: 1 }),
],
});
const entity1Before = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entity1Before,
active: [pro.id],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel_action: "cancel_end_of_cycle",
});
const entity1After = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entity1After,
canceling: [pro.id],
scheduled: [free.id],
});
const entity2After = await autumnV1.entities.get(
customerId,
entities[1].id,
);
await expectCustomerProducts({
customer: entity2After,
active: [pro.id],
});
});
test(`${chalk.yellowBright("default applies to entities 4: stripe cancel schedules free on entity")}`, async () => {
const customerId = "stripe-cancel-entity-default";
const monthlyMessages = items.monthlyMessages({
includedUsage: 100,
});
const free = products.base({
id: "free",
isDefault: true,
items: [monthlyMessages],
});
const pro = products.pro({
id: "pro",
items: [monthlyMessages],
});
const { autumnV1, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
s.entities({
count: 1,
featureId: TestFeature.Users,
defaultGroup: customerId,
}),
],
actions: [s.attach({ productId: pro.id, entityIndex: 0 })],
});
const entityBefore = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entityBefore,
active: [pro.id],
});
const subscriptionId = await getEntitySubscriptionId({
ctx,
customerId,
entityId: entities[0].id,
productId: pro.id,
});
await ctx.stripeCli.subscriptions.cancel(subscriptionId);
await timeout(12000);
const entityAfter = await autumnV1.entities.get(customerId, entities[0].id);
await expectCustomerProducts({
customer: entityAfter,
active: [free.id],
notPresent: [pro.id],
});
});
});

View File

@@ -0,0 +1,137 @@
/**
* Invoice Created Webhook Tests - Consumable Edge Cases
*
* Tests for edge case scenarios involving consumable (usage-in-arrear) prices
* during downgrades, multiple subscriptions, and complex billing scenarios.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { db } from "@/db/initDrizzle";
import { OrgService } from "@/internal/orgs/OrgService";
import { expectCustomerFeatureCorrect } from "../billing/utils/expectCustomerFeatureCorrect";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Addon with separate subscription + consumable
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Pro ($20/mo) with consumable messages (100 included, $0.10/unit)
* - Recurring Addon ($20/mo) with consumable words (50 included, $0.05/unit)
* - Addon attached with new_billing_subscription: true (separate Stripe subscription)
* - Track 200 messages (100 overage) and 150 words (100 overage)
* - Advance to next billing cycle
*
* Expected Result:
* - Pro invoice: $20 base + $10 message overage = $30
* - Addon invoice: $20 base + $5 word overage = $25
* - Each subscription's invoice has its own product's overage
*/
test.concurrent(`${chalk.yellowBright("skip overage submission 1")}`, async () => {
const customerId = "skip-overage-submission";
const consumableMessagesItem = items.consumableMessages({
includedUsage: 100,
});
const pro = products.pro({
id: "pro",
items: [consumableMessagesItem],
});
// Save original org config and enable void_invoices_on_subscription_deletion
// This must be set in the database because webhooks read config from DB, not request headers
const { ctx, autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
// 1. Attach Pro
s.attach({ productId: pro.id }),
],
});
await OrgService.update({
db: db,
orgId: ctx.org.id,
updates: {
config: {
...ctx.org.config,
skip_overage_submission: true,
},
},
});
// Verify final state
const customerAfterAdvance =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Both products should be active
await expectProductActive({
customer: customerAfterAdvance,
productId: pro.id,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 200,
});
await timeout(2000);
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
// Should have 4 invoices:
// 1. Initial Pro ($20)
// 3. Pro renewal: $20 base + $10 overage = $30
const customerAfterAdvance2 =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerAfterAdvance2,
count: 2,
latestTotal: 20,
latestInvoiceProductId: pro.id,
});
expectCustomerFeatureCorrect({
customer: customerAfterAdvance2,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// Verify both balances are reset correctly
expect(customerAfterAdvance2.features[TestFeature.Messages].balance).toBe(
100,
);
await OrgService.update({
db: db,
orgId: ctx.org.id,
updates: {
config: {
...ctx.org.config,
skip_overage_submission: false,
},
},
});
});

View File

@@ -34,6 +34,7 @@ type FeatureOption = {
type EntityConfig = {
count: number;
featureId: string;
defaultGroup?: string;
};
type GeneratedEntity = {
@@ -314,16 +315,20 @@ const products = ({
* Entities are auto-generated with ids "ent-1", "ent-2", etc.
* @param count - Number of entities to create
* @param featureId - Feature ID for all entities (e.g., TestFeature.Users)
* @param defaultGroup - Optional default_group passed via customer_data.internal_options
* @example s.entities({ count: 2, featureId: TestFeature.Users })
* @example s.entities({ count: 1, featureId: TestFeature.Users, defaultGroup: "my-customer" })
*/
const entities = ({
count,
featureId,
defaultGroup,
}: {
count: number;
featureId: string;
defaultGroup?: string;
}): ConfigFn => {
return (config) => ({ ...config, entityConfig: { count, featureId } });
return (config) => ({ ...config, entityConfig: { count, featureId, defaultGroup } });
};
/**
@@ -1234,10 +1239,18 @@ export async function initScenario({
"Cannot create entities: customerId is required when using s.entities()",
);
}
const defaultGroup = config.entityConfig?.defaultGroup;
const entityDefs = generatedEntities.map((e) => ({
id: e.id,
name: e.name,
feature_id: e.featureId,
...(defaultGroup && {
customer_data: {
internal_options: {
default_group: defaultGroup,
},
},
}),
}));
await autumnV1.entities.create(customerId, entityDefs);
}

View File

@@ -29,6 +29,10 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({
// - 'prorate_immediately' (default): Invoice line items are charged immediately
// - 'next_cycle_only': Do NOT create any charges due to the attach
billing_behavior: BillingBehaviorSchema.optional(),
// For importing an existing subscription...?
processor_subscription_id: z.string().optional(),
no_billing_changes: z.boolean().optional(),
});
export const AttachParamsV0Schema = ExtAttachParamsV0Schema.extend({

View File

@@ -39,6 +39,13 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
description:
"Custom line items that override the auto-generated proration invoice. Only valid for immediate plan changes (eg. upgrades or one off plans).",
}),
processor_subscription_id: z.string().optional().meta({
internal: true,
}),
no_billing_changes: z.boolean().optional().meta({
internal: true,
}),
});
export type AttachParamsV1 = z.infer<typeof AttachParamsV1Schema>;

View File

@@ -1,3 +1,4 @@
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums";
import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { BillingBehaviorSchema } from "../common/billingBehavior";
@@ -22,6 +23,16 @@ export const ExtUpdateSubscriptionV0ParamsSchema =
// - 'prorate_immediately' (default): Invoice line items are charged immediately
// - 'next_cycle_only': Do NOT create any charges due to the update
billing_behavior: BillingBehaviorSchema.optional(),
processor_subscription_id: z.string().nullable().optional(),
no_billing_changes: z.boolean().optional(),
status: z
.enum([
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Expired,
])
.optional(),
});
export const UpdateSubscriptionV0ParamsSchema =

View File

@@ -1,3 +1,4 @@
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums";
import { z } from "zod/v4";
import { BillingParamsBaseV1Schema } from "../common/billingParamsBase/billingParamsBaseV1";
import { CancelActionSchema } from "../common/cancelAction";
@@ -12,23 +13,48 @@ export const ExtUpdateSubscriptionV1ParamsSchema =
description:
"Action to perform for cancellation. 'cancel_immediately' cancels now with prorated refund, 'cancel_end_of_cycle' cancels at period end, 'uncancel' reverses a pending cancellation.",
}),
processor_subscription_id: z.string().nullable().optional().meta({
internal: true,
}),
no_billing_changes: z.boolean().optional().meta({
internal: true,
}),
status: z
.enum([
CusProductStatus.Active,
CusProductStatus.PastDue,
CusProductStatus.Expired,
])
.optional()
.meta({
internal: true,
}),
});
const UPDATE_FIELDS = [
"feature_quantities",
"version",
"customize",
"cancel_action",
"processor_subscription_id",
"no_billing_changes",
"status",
] as const satisfies (keyof z.input<
typeof ExtUpdateSubscriptionV1ParamsSchema
>)[];
export const UpdateSubscriptionV1ParamsSchema =
ExtUpdateSubscriptionV1ParamsSchema.extend({
customer_product_id: z.string().optional().meta({
internal: true,
}),
}).refine(
(data) =>
data.feature_quantities !== undefined ||
data.version !== undefined ||
data.customize !== undefined ||
data.cancel_action !== undefined,
{
message:
"At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)",
},
);
}).refine((data) => UPDATE_FIELDS.some((key) => data[key] !== undefined), {
message:
"At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)",
});
export type UpdateSubscriptionV1Params = z.infer<
typeof UpdateSubscriptionV1ParamsSchema

View File

@@ -68,4 +68,6 @@ export interface BillingContext {
billingVersion: BillingVersion;
successUrl?: string;
skipBillingChanges?: boolean;
}

View File

@@ -54,6 +54,8 @@ export const AutumnBillingPlanSchema = z.object({
ended_at: z.number().nullish(),
scheduled_ids: z.array(z.string()).optional(),
subscription_ids: z.array(z.string()).optional(),
}),
})
.optional(),

View File

@@ -21,6 +21,12 @@ export const OrgConfigSchema = z.object({
invoice_memos: z.boolean().default(false),
entity_product: z.boolean().default(false),
void_invoices_on_subscription_deletion: z.boolean().default(false),
// default
default_applies_to_entities: z.boolean().default(false),
// skip_overage_submission
skip_overage_submission: z.boolean().default(false),
});
export type OrgConfig = z.infer<typeof OrgConfigSchema>;

View File

@@ -1,5 +1,11 @@
import { CusProductStatus } from "@models/cusProductModels/cusProductEnums.js";
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels.js";
import type {
CusProduct,
FullCusProduct,
} from "@models/cusProductModels/cusProductModels.js";
import type { Product } from "@models/productModels/productModels";
import { orgDefaultAppliesToEntities } from "../../..";
import type { SharedContext } from "../../../types/sharedContext";
import {
isFreeProduct,
isOneOffProduct,
@@ -12,7 +18,9 @@ import { cusProductToPrices } from "../convertCusProduct";
// PRODUCT TYPE CHECKS
// ============================================================================
export const isCustomerProductMain = (customerProduct?: FullCusProduct) => {
export const isCustomerProductMain = (
customerProduct?: CusProduct & { product: Product },
) => {
if (!customerProduct) return false;
return !customerProduct.product.is_add_on;
};
@@ -232,16 +240,32 @@ export const customerProductsHaveDuplicateProductId = ({
);
};
export const isCustomerProductEntityScoped = (
customerProduct?: FullCusProduct,
) => {
export const isCustomerProductEntityScoped = (customerProduct?: CusProduct) => {
if (!customerProduct) return false;
return notNullish(customerProduct.internal_entity_id);
};
export const isCustomerProductCustomerScoped = (
customerProduct?: FullCusProduct,
customerProduct?: CusProduct,
) => {
if (!customerProduct) return false;
return nullish(customerProduct.internal_entity_id);
};
export const customerProductEligibleForDefaultProduct = ({
ctx,
customerProduct,
}: {
ctx: SharedContext;
customerProduct: FullCusProduct;
}) => {
const orgDefaultScopeMatches = orgDefaultAppliesToEntities({ ctx })
? isCustomerProductEntityScoped(customerProduct)
: isCustomerProductCustomerScoped(customerProduct);
if (!orgDefaultScopeMatches) return false;
if (!isCustomerProductMain(customerProduct)) return false;
if (!isCustomerProductRecurring(customerProduct)) return false;
return true;
};

View File

@@ -0,0 +1,27 @@
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
import type { FullProduct } from "@models/productModels/productModels";
import type { SharedContext } from "../../../types";
import { customerProductEligibleForDefaultProduct } from "../classifyCustomerProduct/classifyCustomerProduct";
export const customerProductToDefaultProduct = ({
ctx,
customerProduct,
defaultProducts,
}: {
ctx: SharedContext;
customerProduct: FullCusProduct;
defaultProducts: FullProduct[];
}) => {
const eligibleForDefaultProduct = customerProductEligibleForDefaultProduct({
ctx,
customerProduct,
});
if (!eligibleForDefaultProduct) return undefined;
return defaultProducts.find(
(p) =>
p.group === customerProduct.product.group &&
p.id !== customerProduct.product.id,
);
};

View File

@@ -1,4 +1,4 @@
import { AppEnv } from "../../index.js";
import { AppEnv, type SharedContext } from "../../index.js";
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import type { Organization } from "../../models/orgModels/orgTable.js";
@@ -26,3 +26,11 @@ export const orgToReturnUrl = ({
return org.stripe_config?.success_url || "https://useautumn.com";
}
};
export const orgDefaultAppliesToEntities = ({
ctx,
}: {
ctx: SharedContext;
}) => {
return ctx.org.config.default_applies_to_entities;
};