From 3d73d0eccbf06e5bf194b4c3fc2093fa0178932e Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 5 Mar 2026 19:03:53 +0000 Subject: [PATCH] fix: default at the entity level, no_billing_changes flag in attach --- server/src/cron/cronInit.ts | 2 +- .../productCron/fetchExpiredTrialProducts.ts | 110 +++++++ .../productCron/processExpiredTrialRow.ts | 66 ++++ server/src/cron/productCron/runProductCron.ts | 150 +++++---- ...rocessConsumablePricesForInvoiceCreated.ts | 5 +- ...sConsumablePricesForSubscriptionDeleted.ts | 3 +- .../scheduleDefaultProducts.ts | 17 +- .../attach/setup/setupAttachBillingContext.ts | 8 +- .../setup/setupMultiAttachBillingContext.ts | 5 +- .../cancel/computeDefaultCustomerProduct.ts | 13 +- .../compute/computeFieldUpdates.ts | 29 ++ .../compute/computeUpdateSubscriptionPlan.ts | 13 + .../compute/customPlan/computeCustomPlan.ts | 4 + .../computeCustomPlanNewCustomerProduct.ts | 9 + .../logs/logUpdateSubscriptionContext.ts | 3 +- .../setup/setupDefaultProductContext.ts | 18 +- .../setupUpdateSubscriptionBillingContext.ts | 18 ++ .../billing/v2/execute/executeBillingPlan.ts | 14 +- .../evaluateStripeBillingPlan.ts | 4 + .../fetchStripeSubscriptionForBilling.ts | 29 +- .../stripe/setup/setupStripeBillingContext.ts | 37 ++- .../attachParams/convertToParams.ts | 3 +- .../actions/activateFreeDefaultProduct.ts | 30 +- .../entities/actions/batchCreateEntities.ts | 10 +- .../attachDefaultProductsToEntities.ts | 59 ++++ .../src/internal/products/ProductService.ts | 10 +- server/tests/_groups/domains/temp.ts | 8 - server/tests/_groups/index.ts | 1 - server/tests/_groups/temp.ts | 39 ++- .../params/processor-subscription-id.test.ts | 191 +++++++++++ .../subscription-deleted.test.ts | 6 +- ...pdate-processor-no-billing-changes.test.ts | 6 + .../params/update-processor-status.test.ts | 6 + .../update-processor-subscription-id.test.ts | 181 +++++++++++ .../stripe/createCustomStripeSubscription.ts | 49 +++ .../expectStripeSubscriptionUnchanged.ts | 38 +++ .../default-applies-to-entity.test.ts | 306 ++++++++++++++++++ .../skip-overage-submission.test.ts | 137 ++++++++ .../tests/utils/testInitUtils/initScenario.ts | 15 +- shared/api/billing/attachV2/attachParamsV0.ts | 4 + shared/api/billing/attachV2/attachParamsV1.ts | 7 + .../updateSubscriptionV0Params.ts | 11 + .../updateSubscriptionV1Params.ts | 48 ++- .../billingModels/context/billingContext.ts | 2 + .../billingModels/plan/autumnBillingPlan.ts | 2 + shared/models/orgModels/orgConfig.ts | 6 + .../classifyCustomerProduct.ts | 36 ++- .../customerProductToDefaultProduct.ts | 27 ++ shared/utils/orgUtils/convertOrgUtils.ts | 10 +- 49 files changed, 1639 insertions(+), 166 deletions(-) create mode 100644 server/src/cron/productCron/fetchExpiredTrialProducts.ts create mode 100644 server/src/cron/productCron/processExpiredTrialRow.ts create mode 100644 server/src/internal/billing/v2/actions/updateSubscription/compute/computeFieldUpdates.ts create mode 100644 server/src/internal/entities/actions/batchCreateEntities/attachDefaultProductsToEntities.ts delete mode 100644 server/tests/_groups/domains/temp.ts create mode 100644 server/tests/integration/billing/attach/params/processor-subscription-id.test.ts create mode 100644 server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts create mode 100644 server/tests/integration/billing/update-subscription/params/update-processor-status.test.ts create mode 100644 server/tests/integration/billing/update-subscription/params/update-processor-subscription-id.test.ts create mode 100644 server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts create mode 100644 server/tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged.ts create mode 100644 server/tests/integration/org-config/default-applies-to-entity.test.ts create mode 100644 server/tests/integration/org-config/skip-overage-submission.test.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct.ts diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index c14283b94..275899164 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -21,8 +21,8 @@ const main = async () => { logger, }; await Promise.all([ + runProductCron({ ctx }), runResetCron({ ctx }), - runProductCron(), runInvoiceCron({ ctx }), runOneOffCleanup({ ctx }), ]); diff --git a/server/src/cron/productCron/fetchExpiredTrialProducts.ts b/server/src/cron/productCron/fetchExpiredTrialProducts.ts new file mode 100644 index 000000000..4c70a7f57 --- /dev/null +++ b/server/src/cron/productCron/fetchExpiredTrialProducts.ts @@ -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; + customer: InferSelectModel; +}; + +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 => { + 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; +}; diff --git a/server/src/cron/productCron/processExpiredTrialRow.ts b/server/src/cron/productCron/processExpiredTrialRow.ts new file mode 100644 index 000000000..5958f8f26 --- /dev/null +++ b/server/src/cron/productCron/processExpiredTrialRow.ts @@ -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; + customer: InferSelectModel; + 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", + }); +}; diff --git a/server/src/cron/productCron/runProductCron.ts b/server/src/cron/productCron/runProductCron.ts index c63dd8150..32d46342c 100644 --- a/server/src/cron/productCron/runProductCron.ts +++ b/server/src/cron/productCron/runProductCron.ts @@ -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); } diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts index f9aa9f7ac..f07d5411a 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts index 4893c193e..a2d033825 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/processConsumablePricesForSubscriptionDeleted.ts @@ -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 }); diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/scheduleDefaultProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/scheduleDefaultProducts.ts index 181289604..e2022fd15 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/scheduleDefaultProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/scheduleDefaultProducts.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 61f2a5b56..29b04e0d6 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts index 01f43e12d..6d69613c6 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts @@ -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({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeDefaultCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeDefaultCustomerProduct.ts index bc2ac501f..a7b683989 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeDefaultCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeDefaultCustomerProduct.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeFieldUpdates.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeFieldUpdates.ts new file mode 100644 index 000000000..a5d334ca4 --- /dev/null +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeFieldUpdates.ts @@ -0,0 +1,29 @@ +import type { + AutumnBillingPlan, + UpdateSubscriptionV1Params, +} from "@autumn/shared"; + +type CusProductFieldUpdates = NonNullable< + NonNullable["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; +}; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index e6b4440a4..59831fdf3 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -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 }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index 4050bd87a..7c0c806c2 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 3ee8c17e6..8194b912f 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -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 } : {}), }, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts index c31bafaad..a00fa8b15 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext.ts @@ -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, }, }, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts index b2379ea88..554ba7c58 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 47cc148c0..3bf00033d 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index 850e3665c..6f8c1c932 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -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 => { - 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 diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index 1a441697c..1342104f0 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -29,6 +29,10 @@ export const evaluateStripeBillingPlan = async ({ autumnBillingPlan: AutumnBillingPlan; checkoutMode?: CheckoutMode; }): Promise => { + if (billingContext.skipBillingChanges) { + return {}; + } + await initStripeResourcesForBillingPlan({ ctx, autumnBillingPlan, diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts index d99b6d1e0..20c6bf195 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts @@ -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 => { - 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; }; diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts index af5b3a4dc..2423bfa6d 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -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 { diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts index c2845ce38..c083f8ca3 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts @@ -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: [], }; diff --git a/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts b/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts index 0d296f3fc..29741efb7 100644 --- a/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts +++ b/server/src/internal/customers/cusProducts/actions/activateFreeDefaultProduct.ts @@ -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 => { 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: [], diff --git a/server/src/internal/entities/actions/batchCreateEntities.ts b/server/src/internal/entities/actions/batchCreateEntities.ts index 82de9acf2..d25d0e620 100644 --- a/server/src/internal/entities/actions/batchCreateEntities.ts +++ b/server/src/internal/entities/actions/batchCreateEntities.ts @@ -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; diff --git a/server/src/internal/entities/actions/batchCreateEntities/attachDefaultProductsToEntities.ts b/server/src/internal/entities/actions/batchCreateEntities/attachDefaultProductsToEntities.ts new file mode 100644 index 000000000..2d54cc4c2 --- /dev/null +++ b/server/src/internal/entities/actions/batchCreateEntities/attachDefaultProductsToEntities.ts @@ -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, + }, + }); + } +}; diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 04d896267..2acdf9703 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -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[]; } diff --git a/server/tests/_groups/domains/temp.ts b/server/tests/_groups/domains/temp.ts deleted file mode 100644 index e193af8dc..000000000 --- a/server/tests/_groups/domains/temp.ts +++ /dev/null @@ -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"], -}; diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts index bd907a9eb..52915d097 100644 --- a/server/tests/_groups/index.ts +++ b/server/tests/_groups/index.ts @@ -50,7 +50,6 @@ const allGroups: TestGroup[] = [ webhooks, advanced, misc, - temp, ]; export const getAllGroups = (): TestGroup[] => allGroups; diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 6e9abeb4b..d2c4b18a1 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -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", ], }; diff --git a/server/tests/integration/billing/attach/params/processor-subscription-id.test.ts b/server/tests/integration/billing/attach/params/processor-subscription-id.test.ts new file mode 100644 index 000000000..c4bc6209f --- /dev/null +++ b/server/tests/integration/billing/attach/params/processor-subscription-id.test.ts @@ -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({ + customer_id: customerId, + product_id: pro.id, + processor_subscription_id: stripeSubscription.id, + }); + + const customerAfter = await autumnV1.customers.get(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({ + customer_id: customerId, + product_id: pro.id, + processor_subscription_id: stripeSubscription.id, + }); + + await expectCustomerProducts({ + customer: await autumnV1.customers.get(customerId), + active: [pro.id], + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + processor_subscription_id: stripeSubscription.id, + no_billing_changes: true, + }); + + const customerAfterUpgrade = + await autumnV1.customers.get(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, + }); +}); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts index 4559b870a..e49f4bce5 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-deleted/subscription-deleted.test.ts @@ -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 = diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts new file mode 100644 index 000000000..4fad852a2 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts @@ -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 () => {}); diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-status.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-status.test.ts new file mode 100644 index 000000000..4fad852a2 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/params/update-processor-status.test.ts @@ -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 () => {}); diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-subscription-id.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-subscription-id.test.ts new file mode 100644 index 000000000..b918dcc15 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/params/update-processor-subscription-id.test.ts @@ -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({ + 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({ + 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(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({ + 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(customerId); + await expectCustomerProducts({ customer: customerAfter, active: [pro.id] }); + + const customerV2After = await autumnV2.customers.get( + 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, + }); +}); diff --git a/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts b/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts new file mode 100644 index 000000000..0de748e34 --- /dev/null +++ b/server/tests/integration/billing/utils/stripe/createCustomStripeSubscription.ts @@ -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 => { + 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 }, + }, + }, + ], + }); +}; diff --git a/server/tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged.ts b/server/tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged.ts new file mode 100644 index 000000000..3b332162e --- /dev/null +++ b/server/tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged.ts @@ -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); + } +}; diff --git a/server/tests/integration/org-config/default-applies-to-entity.test.ts b/server/tests/integration/org-config/default-applies-to-entity.test.ts new file mode 100644 index 000000000..24d9085fe --- /dev/null +++ b/server/tests/integration/org-config/default-applies-to-entity.test.ts @@ -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(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], + }); + }); +}); diff --git a/server/tests/integration/org-config/skip-overage-submission.test.ts b/server/tests/integration/org-config/skip-overage-submission.test.ts new file mode 100644 index 000000000..14a4d03a1 --- /dev/null +++ b/server/tests/integration/org-config/skip-overage-submission.test.ts @@ -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(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(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, + }, + }, + }); +}); diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index eb5de08b9..8a3e974f9 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -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); } diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index 41304e444..885dc27a6 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -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({ diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index d0b02794b..6f186743b 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -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; diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index f3b2e2a25..0203b1268 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -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 = diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts index 3b833b525..5c2767fe4 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts @@ -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 diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index 3e7a6c1af..6eb94ba21 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -68,4 +68,6 @@ export interface BillingContext { billingVersion: BillingVersion; successUrl?: string; + + skipBillingChanges?: boolean; } diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 64b1a4700..47ab87dcd 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -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(), diff --git a/shared/models/orgModels/orgConfig.ts b/shared/models/orgModels/orgConfig.ts index 6088e4d6f..620ab6048 100644 --- a/shared/models/orgModels/orgConfig.ts +++ b/shared/models/orgModels/orgConfig.ts @@ -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; diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index 8743e2f1b..9d1a6533f 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -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; +}; diff --git a/shared/utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct.ts b/shared/utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct.ts new file mode 100644 index 000000000..b941a11c7 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/customerProductToDefaultProduct.ts @@ -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, + ); +}; diff --git a/shared/utils/orgUtils/convertOrgUtils.ts b/shared/utils/orgUtils/convertOrgUtils.ts index cfb25b20a..06ef7722a 100644 --- a/shared/utils/orgUtils/convertOrgUtils.ts +++ b/shared/utils/orgUtils/convertOrgUtils.ts @@ -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; +};