diff --git a/scripts/db/commands/generate.ts b/scripts/db/commands/generate.ts index 2a694ceec..6bc2c8dac 100644 --- a/scripts/db/commands/generate.ts +++ b/scripts/db/commands/generate.ts @@ -1,7 +1,12 @@ import { run } from "../helpers/spawn.ts"; import { REPO_ROOT } from "../helpers/paths.ts"; +import { loadWorktreeEnvLocal } from "../helpers/env.ts"; export async function cmdGenerate(): Promise { + // If a worktree is enabled, prefer its DATABASE_URL so drizzle-kit + // diffs against the worktree's isolated DB. + loadWorktreeEnvLocal(); + const { code } = await run( "bun", ["-F", "@autumn/shared", "db:generate"], diff --git a/scripts/db/helpers/env.ts b/scripts/db/helpers/env.ts index d38fd8622..93d0ec1d2 100644 --- a/scripts/db/helpers/env.ts +++ b/scripts/db/helpers/env.ts @@ -1,7 +1,35 @@ import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { REPO_ROOT } from "./paths.ts"; export type Env = "dev" | "staging" | "prod"; +/** + * If `bun dw` has enabled this worktree, `server/.env.local` exists and + * contains the worktree-specific DATABASE_URL pointing at its isolated DB. + * Detect that file and load DATABASE_URL from it so `bun db` operations + * affect the worktree's DB instead of the shared dev DB. + * + * Returns true if we loaded a worktree DATABASE_URL (caller should skip + * the infisical wrap), false otherwise. + */ +export function loadWorktreeEnvLocal(): boolean { + const envLocalPath = resolve(REPO_ROOT, "server/.env.local"); + if (!existsSync(envLocalPath)) return false; + + const contents = readFileSync(envLocalPath, "utf-8"); + const match = contents.match(/^DATABASE_URL=(.+)$/m); + if (!match) return false; + + const databaseUrl = match[1].trim(); + if (!databaseUrl) return false; + + process.env.DATABASE_URL = databaseUrl; + console.log(`[db] using DATABASE_URL from server/.env.local (worktree)`); + return true; +} + const VALID_ENVS: readonly Env[] = ["dev", "staging", "prod"] as const; export function parseEnv(argv: readonly string[]): Env { @@ -45,6 +73,11 @@ export async function wrapInInfisical(env: Env): Promise { return false; } + // If a worktree is active (bun dw enable wrote server/.env.local), prefer + // its DATABASE_URL over the infisical-injected one so `bun db` affects + // the worktree's isolated DB. + if (loadWorktreeEnvLocal()) return false; + const args = [ "run", `--env=${env}`, diff --git a/server/src/cron/productCron/processExpiredTrialRow.ts b/server/src/cron/productCron/processExpiredTrialRow.ts index b4d3a5161..f80e1f776 100644 --- a/server/src/cron/productCron/processExpiredTrialRow.ts +++ b/server/src/cron/productCron/processExpiredTrialRow.ts @@ -2,11 +2,13 @@ import { CusProductStatus, type customerProducts, type customers, + type FullCusProduct, 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 { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; import { CusService } from "@/internal/customers/CusService"; import { activateFreeDefaultProduct } from "@/internal/customers/cusProducts/actions/activateFreeDefaultProduct"; import { tryProcessRevertExpiry } from "@/internal/customers/cusProducts/actions/revertTrialExpiry"; @@ -24,6 +26,7 @@ export const processExpiredTrialRow = async ({ customer: InferSelectModel; defaultProducts: FullProduct[]; }) => { + // Revert path owns its own webhook emission. const reverted = await tryProcessRevertExpiry({ ctx, customerProduct, @@ -31,6 +34,10 @@ export const processExpiredTrialRow = async ({ }); if (reverted) return; + // Standard path: snapshot fullCustomer BEFORE mutations so the webhook + // payload reflects pre-expiry state in `previous_attributes`. Default + // RELEVANT_STATUSES is sufficient — the trial cusProduct is Active + // (with a past trial_ends_at) at this point. const fullCustomer = await CusService.getFull({ ctx, idOrInternalId: customer.internal_id, @@ -38,29 +45,29 @@ export const processExpiredTrialRow = async ({ withSubs: true, }); - const fullCustomerProduct = fullCustomer.customer_products.find( + const trialFullCusProduct = fullCustomer.customer_products.find( (cp) => cp.id === customerProduct.id, ); - - if (!fullCustomerProduct) return; + if (!trialFullCusProduct) return; const defaultProduct = customerProductToDefaultProduct({ ctx, - customerProduct: fullCustomerProduct, + customerProduct: trialFullCusProduct, defaultProducts, }); + let activatedDefault: FullCusProduct | undefined; if (defaultProduct) { - await activateFreeDefaultProduct({ + activatedDefault = await activateFreeDefaultProduct({ ctx, - customerProduct: fullCustomerProduct, + customerProduct: trialFullCusProduct, fullCustomer, defaultProduct, }); } await CusProductService.update({ ctx, - cusProductId: fullCustomerProduct.id, + cusProductId: trialFullCusProduct.id, updates: { status: CusProductStatus.Expired, }, @@ -71,4 +78,20 @@ export const processExpiredTrialRow = async ({ customerId: fullCustomer.id ?? "", source: "productCron", }); + + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan: { + customerId: fullCustomer.id ?? fullCustomer.internal_id, + insertCustomerProducts: activatedDefault ? [activatedDefault] : [], + updateCustomerProducts: [ + { + customerProduct: trialFullCusProduct, + updates: { status: CusProductStatus.Expired }, + }, + ], + }, + originalFullCustomer: fullCustomer, + tags: ["trial_ended"], + }); }; diff --git a/server/src/cron/productCron/runProductCron.ts b/server/src/cron/productCron/runProductCron.ts index fc26a9272..0946a1011 100644 --- a/server/src/cron/productCron/runProductCron.ts +++ b/server/src/cron/productCron/runProductCron.ts @@ -1,12 +1,4 @@ -import { - type AppEnv, - CusProductStatus, - ms, - orgToFeaturesByOrgEnv, -} from "@autumn/shared"; -import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js"; -import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects"; -import { customerProductRepo } from "@/internal/customers/cusProducts/repos"; +import { ms } from "@autumn/shared"; import { ProductService } from "@/internal/products/ProductService"; import type { CronContext } from "../utils/CronContext"; import { @@ -32,12 +24,14 @@ const partitionRevertRows = (rows: ExpiredTrialRow[]) => { const BATCH_SIZE = 250; -const processRevertRows = async ({ +const processRowsInBatches = async ({ ctx, rows, + defaultProducts, }: { ctx: OrgEnvExpiredTrials["ctx"]; rows: ExpiredTrialRow[]; + defaultProducts: Awaited>; }) => { for (let i = 0; i < rows.length; i += BATCH_SIZE) { const batch = rows.slice(i, i + BATCH_SIZE); @@ -47,7 +41,7 @@ const processRevertRows = async ({ ctx, customerProduct: row.customerProduct, customer: row.customer, - defaultProducts: [], + defaultProducts, }), ), ); @@ -84,12 +78,16 @@ export const runProductCron = async ({ const resultsByOrgEnv = await groupByOrgEnv({ results, cronContext }); - for (const { ctx, org, features, rows } of resultsByOrgEnv) { + for (const { ctx, rows } of resultsByOrgEnv) { const { revert: revertRows, standard: standardRows } = partitionRevertRows(rows); if (revertRows.length > 0) { - await processRevertRows({ ctx, rows: revertRows }); + await processRowsInBatches({ + ctx, + rows: revertRows, + defaultProducts: [], + }); } if (standardRows.length === 0) continue; @@ -101,53 +99,15 @@ export const runProductCron = async ({ onlyFree: true, }); - if (defaultProducts.length === 0) { - await customerProductRepo.batchUpdate({ - ctx, - updates: standardRows.map((row) => ({ - id: row.customerProduct.id, - updates: { - status: CusProductStatus.Expired, - }, - })), - }); - const customersToDelete = standardRows.map((row) => ({ - orgId: row.customer.org_id, - env: row.customer.env as AppEnv, - customerId: row.customer.id ?? "", - })); - const featuresByOrgEnv = orgToFeaturesByOrgEnv({ - org, - env: ctx.env, - features, - }); - - await batchInvalidateCachedFullSubjects({ - customers: customersToDelete, - featuresByOrgEnv, - getRedisTargetsForCustomer: () => - getRedisTargetsForCustomer({ - org, - }), - }); - console.log(`Expired ${standardRows.length} customer products`); - continue; - } - - const processBatchSize = 250; - for (let i = 0; i < standardRows.length; i += processBatchSize) { - const batch = standardRows.slice(i, i + processBatchSize); - await Promise.all( - batch.map((row) => - processExpiredTrialRow({ - ctx, - customerProduct: row.customerProduct, - customer: row.customer, - defaultProducts, - }), - ), - ); - } + // Always route through `processExpiredTrialRow` so the + // `billing.updated` webhook (tagged "trial_ended") fires from + // a single emission site — regardless of whether a free default + // is being activated alongside the expiry. + await processRowsInBatches({ + ctx, + rows: standardRows, + defaultProducts, + }); } totalExpired += results.length; diff --git a/server/src/external/stripe/webhookHandlers/common/billingChangeTags.ts b/server/src/external/stripe/webhookHandlers/common/billingChangeTags.ts new file mode 100644 index 000000000..0afd9222d --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/billingChangeTags.ts @@ -0,0 +1,16 @@ +/** + * Helper for tasks to push tags onto an eventContext. Tags are surfaced on + * the `billing.updated` webhook payload as `tags: string[]`. + * + * Only call this when the tag's condition is actually detected — tags should + * describe what happened, not every event. + */ + +export type BillingChangeTaggable = { billingChangeTags: Set }; + +export const addBillingChangeTag = ( + eventContext: BillingChangeTaggable, + tag: string, +): void => { + eventContext.billingChangeTags.add(tag); +}; diff --git a/server/src/external/stripe/webhookHandlers/common/emitBillingChangeWebhook.ts b/server/src/external/stripe/webhookHandlers/common/emitBillingChangeWebhook.ts new file mode 100644 index 000000000..1e7f8eaae --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/emitBillingChangeWebhook.ts @@ -0,0 +1,36 @@ +/** + * Central emission point for `billing.updated` from Stripe webhook + * handlers. Builds the AutumnBillingPlan from the eventContext's tracked + * mutations, gathers tags, and fires the webhook fire-and-forget. + * + * Called as the last step of `handleStripeSubscriptionUpdated` and + * `handleStripeSubscriptionDeleted`, after all tasks have run. + */ + +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; +import type { StripeSubscriptionDeletedContext } from "../handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext"; +import type { StripeSubscriptionUpdatedContext } from "../handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext"; +import { eventContextToAutumnBillingPlan } from "./eventContextToAutumnBillingPlan"; + +type EventContext = + | StripeSubscriptionUpdatedContext + | StripeSubscriptionDeletedContext; + +export const emitBillingChangeWebhook = ({ + ctx, + eventContext, +}: { + ctx: StripeWebhookContext; + eventContext: EventContext; +}): void => { + const autumnBillingPlan = eventContextToAutumnBillingPlan(eventContext); + const tags = Array.from(eventContext.billingChangeTags); + + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan, + originalFullCustomer: eventContext.fullCustomer, + tags, + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan.ts b/server/src/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan.ts new file mode 100644 index 000000000..aca7fd6e3 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan.ts @@ -0,0 +1,18 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; +import type { StripeSubscriptionDeletedContext } from "../handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext"; +import type { StripeSubscriptionUpdatedContext } from "../handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext"; + +type EventContext = + | StripeSubscriptionUpdatedContext + | StripeSubscriptionDeletedContext; + +export const eventContextToAutumnBillingPlan = ( + eventContext: EventContext, +): AutumnBillingPlan => + ({ + customerId: + eventContext.fullCustomer.id ?? eventContext.fullCustomer.internal_id, + insertCustomerProducts: eventContext.insertedCustomerProducts, + updateCustomerProducts: eventContext.updatedCustomerProducts, + deleteCustomerProducts: eventContext.deletedCustomerProducts, + }) as AutumnBillingPlan; diff --git a/server/src/external/stripe/webhookHandlers/common/index.ts b/server/src/external/stripe/webhookHandlers/common/index.ts index 5d0b9eb63..940901505 100644 --- a/server/src/external/stripe/webhookHandlers/common/index.ts +++ b/server/src/external/stripe/webhookHandlers/common/index.ts @@ -1,5 +1,8 @@ +export { addBillingChangeTag } from "./billingChangeTags"; export { cusProductsToRenewalLineItems } from "./cusProductsToRenewalLineItems"; +export { emitBillingChangeWebhook } from "./emitBillingChangeWebhook"; export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems"; +export { eventContextToAutumnBillingPlan } from "./eventContextToAutumnBillingPlan"; export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking"; export { logCustomerProductUpdates } from "./logCustomerProductUpdates"; export { storeRenewalLineItems } from "./storeRenewalLineItems"; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts index e78a74767..0356f4712 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts @@ -7,10 +7,11 @@ import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookH import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout"; import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; -import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock"; import { persistDeferredCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule"; +import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated"; import { MetadataService } from "@/internal/metadata/MetadataService"; import { workflows } from "@/queue/workflows"; @@ -94,6 +95,13 @@ export const handleCheckoutSessionMetadataV2 = async ({ billingContext: updatedDeferredData.billingContext, }); + // Fire-and-forget billing.updated webhook (mirrors executeBillingPlan) + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan: updatedDeferredData.billingPlan.autumn, + originalFullCustomer: updatedDeferredData.billingContext.fullCustomer, + }); + // Delete metadata after successful execution await MetadataService.delete({ db: ctx.db, id: metadata.id }); diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.ts index 32c544227..330eb4ab1 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.ts @@ -1,6 +1,6 @@ import type Stripe from "stripe"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext"; -import { logCustomerProductUpdates } from "../common"; +import { emitBillingChangeWebhook, logCustomerProductUpdates } from "../common"; import { setupStripeSubscriptionDeletedContext } from "./setupStripeSubscriptionDeletedContext"; import { expireAndActivateCustomerProducts } from "./tasks/expireAndActivateCustomerProducts"; import { processConsumablePricesForSubscriptionDeleted } from "./tasks/processConsumablePricesForSubscriptionDeleted"; @@ -48,4 +48,10 @@ export const handleStripeSubscriptionDeleted = async ({ ctx, eventContext, }); + + // Task 5: Emit billing.updated webhook (fire-and-forget) if anything changed + emitBillingChangeWebhook({ + ctx, + eventContext, + }); }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts index fd8f3f67b..4f4ab4832 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts @@ -43,6 +43,9 @@ export interface StripeSubscriptionDeletedContext { deletedCustomerProducts: FullCusProduct[]; /** Tracks all insertions (new customer products created) during this handler */ insertedCustomerProducts: FullCusProduct[]; + /** Tags accumulated by tasks during this handler — appended to the + * `billing.updated` webhook payload. */ + billingChangeTags: Set; } /** @@ -135,5 +138,6 @@ export const setupStripeSubscriptionDeletedContext = async ({ updatedCustomerProducts: [], deletedCustomerProducts: [], insertedCustomerProducts: [], + billingChangeTags: new Set(), }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts index f80a933ec..91f3ab9cb 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts @@ -2,11 +2,12 @@ import type Stripe from "stripe"; import { handleStripeSubscriptionCanceled } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/handleStripeSubscriptionCanceled.js"; import { syncAutumnSubscription } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/syncAutumnSubscription.js"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; -import { logCustomerProductUpdates } from "../common"; +import { emitBillingChangeWebhook, logCustomerProductUpdates } from "../common"; import { setupStripeSubscriptionUpdatedContext } from "./setupStripeSubscriptionUpdatedContext.js"; import { handleCancelOnPastDue } from "./tasks/handleCancelOnPastDue.js"; import { handleSchedulePhaseChanges } from "./tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.js"; import { handleStripeSubscriptionRenewed } from "./tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.js"; +import { handleStripeSubscriptionTrialEnded } from "./tasks/handleStripeSubscriptionTrialEnded/handleStripeSubscriptionTrialEnded.js"; import { syncCustomerProductStatus } from "./tasks/syncCustomerProductStatus/syncCustomerProductStatus.js"; export const handleStripeSubscriptionUpdated = async ({ @@ -64,9 +65,21 @@ export const handleStripeSubscriptionUpdated = async ({ subscriptionUpdatedContext, }); - // 6. Log all customer product updates + // 6. Detect trial-end transition (tags only — no DB writes) + handleStripeSubscriptionTrialEnded({ + ctx, + subscriptionUpdatedContext, + }); + + // 7. Log all customer product updates logCustomerProductUpdates({ ctx, eventContext: subscriptionUpdatedContext, }); + + // 8. Emit billing.updated webhook (fire-and-forget) if anything changed + emitBillingChangeWebhook({ + ctx, + eventContext: subscriptionUpdatedContext, + }); }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/setupStripeSubscriptionUpdatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/setupStripeSubscriptionUpdatedContext.ts index 67d6a2dda..91b8d0459 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/setupStripeSubscriptionUpdatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/setupStripeSubscriptionUpdatedContext.ts @@ -42,5 +42,6 @@ export const setupStripeSubscriptionUpdatedContext = async ({ updatedCustomerProducts: [], deletedCustomerProducts: [], insertedCustomerProducts: [], + billingChangeTags: new Set(), }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts index a0c1ade0d..9802eac6c 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts @@ -42,4 +42,11 @@ export interface StripeSubscriptionUpdatedContext { deletedCustomerProducts: FullCusProduct[]; /** Tracks all insertions (new customer products created) during this handler */ insertedCustomerProducts: FullCusProduct[]; + /** + * Tags accumulated by tasks during this handler — appended to the + * `billing.updated` webhook payload. Tasks call + * `addBillingChangeTag` to push their own signal (e.g. "trial_ended", + * "phase_changed") when their condition is detected. + */ + billingChangeTags: Set; } diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts index 0a80f749a..4dc0ed905 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts @@ -2,6 +2,7 @@ import { formatMs, notNullish } from "@autumn/shared"; import { stripeSubscriptionScheduleToPhaseIndex } from "@/external/stripe/subscriptionSchedules/utils/convertStripeSubscriptionScheduleUtils"; import { getStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { addBillingChangeTag } from "../../../common"; import type { StripeSubscriptionUpdatedContext } from "../../stripeSubscriptionUpdatedContext"; import { activateScheduledCustomerProducts } from "./activateScheduledCustomerProducts"; import { expireEndedCustomerProducts } from "./expireEndedCustomerProducts"; @@ -34,6 +35,11 @@ export const handleSchedulePhaseChanges = async ({ return; } + // Snapshot counts so we can detect whether this task actually moved any + // customer products — i.e., a phase truly changed in this event. + const updatesBefore = eventContext.updatedCustomerProducts.length; + const insertsBefore = eventContext.insertedCustomerProducts.length; + // Step 1: Activate scheduled products; checkout trial-end updates have no schedule phase change. await activateScheduledCustomerProducts({ ctx, eventContext }); @@ -42,6 +48,10 @@ export const handleSchedulePhaseChanges = async ({ notNullish(previousAttributes?.items) && notNullish(stripeSubscription.schedule); + // `activateScheduledCustomerProducts` can still mutate cusProducts on + // non-phase-change events (e.g. checkout trial-end flows). Those are + // NOT phase changes — only tag `phase_changed` once the canonical + // Stripe-schedule advance signal is confirmed below. if (!phasePossiblyChanged) return; const stripeSubscriptionSchedule = stripeSubscription.schedule; @@ -60,4 +70,11 @@ export const handleSchedulePhaseChanges = async ({ // Step 3: Release schedule if at last phase await releaseScheduleIfLastPhase({ ctx, eventContext }); + + if ( + eventContext.updatedCustomerProducts.length > updatesBefore || + eventContext.insertedCustomerProducts.length > insertsBefore + ) { + addBillingChangeTag(eventContext, "phase_changed"); + } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionTrialEnded/handleStripeSubscriptionTrialEnded.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionTrialEnded/handleStripeSubscriptionTrialEnded.ts new file mode 100644 index 000000000..529cb49fe --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionTrialEnded/handleStripeSubscriptionTrialEnded.ts @@ -0,0 +1,31 @@ +/** + * Detects a Stripe subscription transitioning out of `trialing` and tags the + * billing change with `trial_ended`. + * + * Signal: `event.data.previous_attributes.status === "trialing"` AND the + * current subscription status is no longer trialing. This is the precise + * trial-end transition — it does NOT fire on every subscription.updated event. + */ + +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { addBillingChangeTag } from "../../../common"; +import type { StripeSubscriptionUpdatedContext } from "../../stripeSubscriptionUpdatedContext"; + +export const handleStripeSubscriptionTrialEnded = ({ + ctx, + subscriptionUpdatedContext, +}: { + ctx: StripeWebhookContext; + subscriptionUpdatedContext: StripeSubscriptionUpdatedContext; +}): void => { + const { previousAttributes, stripeSubscription } = subscriptionUpdatedContext; + + if (previousAttributes.status !== "trialing") return; + if (stripeSubscription.status === "trialing") return; + + ctx.logger.info( + `[trialEnded] customer ${subscriptionUpdatedContext.fullCustomer.id ?? subscriptionUpdatedContext.fullCustomer.internal_id} sub ${stripeSubscription.id} status ${previousAttributes.status} → ${stripeSubscription.status}`, + ); + + addBillingChangeTag(subscriptionUpdatedContext, "trial_ended"); +}; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index aadaae4c7..a161fa618 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -8,6 +8,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated"; import { workflows } from "@/queue/workflows"; @@ -81,5 +82,12 @@ export const executeBillingPlan = async ({ billingContext, }); + // Fire-and-forget: don't block the action on svix delivery + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan: billingPlan.autumn, + originalFullCustomer: billingContext.fullCustomer, + }); + return { stripe: stripeBillingResult }; }; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts new file mode 100644 index 000000000..610ba8e36 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts @@ -0,0 +1,33 @@ +import { + type AutumnBillingPlan, + type BillingChangeResponse, + BillingChangeResponseSchema, + type FullCustomer, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildPlanChanges } from "./buildPlanChanges"; + +export const buildBillingChangeResponse = ({ + ctx: _ctx, + originalFullCustomer, + autumnBillingPlan, + tags = [], +}: { + ctx: AutumnContext; + originalFullCustomer: FullCustomer; + autumnBillingPlan: AutumnBillingPlan; + tags?: string[]; +}): BillingChangeResponse => { + // entity_id comes from the enriched FullCustomer (set when the operation + // is scoped to a single entity via `enrichFullCustomerWithEntity`). + const entityId = originalFullCustomer.entity?.id ?? undefined; + + return BillingChangeResponseSchema.parse({ + object: "billing.updated", + customer_id: + originalFullCustomer.id ?? originalFullCustomer.internal_id, + ...(entityId !== undefined ? { entity_id: entityId } : {}), + plan_changes: buildPlanChanges({ autumnBillingPlan }), + tags, + }); +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts new file mode 100644 index 000000000..8292521fd --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts @@ -0,0 +1,158 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type CustomerPlanChange, +} from "@autumn/shared"; +import { buildPlanItemChanges } from "./buildPlanItemChanges"; +import { buildPreviousAttributes } from "./buildPreviousAttributes"; +import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping"; +import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot"; + +const getChangePlanId = (change: CustomerPlanChange): string | undefined => + change.subscription?.plan_id ?? change.purchase?.plan_id; + +/** + * When a billing action updates a plan in-place, Autumn often creates a new + * customer product (insertCustomerProducts) and expires the old one + * (updateCustomerProducts with status=Expired) — both sharing the same + * plan_id. To consumers that looks like the plan briefly went away and came + * back. Merge those pairs into a single `updated` change so the webhook + * reflects the logical operation. + */ +const collapseSamePlanIdPairs = ( + changes: CustomerPlanChange[], +): CustomerPlanChange[] => { + const consumed = new Set(); + const result: CustomerPlanChange[] = []; + + for (let i = 0; i < changes.length; i++) { + if (consumed.has(i)) continue; + const change = changes[i]; + + if (change.action !== "activated" && change.action !== "expired") { + result.push(change); + continue; + } + + const planId = getChangePlanId(change); + const counterpartAction = + change.action === "activated" ? "expired" : "activated"; + + const pairIdx = changes.findIndex( + (other, j) => + j !== i && + !consumed.has(j) && + other.action === counterpartAction && + getChangePlanId(other) === planId, + ); + + if (pairIdx < 0) { + result.push(change); + continue; + } + + // Mark BOTH ends of the pair consumed — otherwise a later iteration's + // `findIndex` could re-match the current index `i` (the loop's + // top-of-iteration `consumed.has(i)` only guards revisiting `i` as + // the iterator, not as a pairing candidate). + consumed.add(i); + consumed.add(pairIdx); + const activatedChange = change.action === "activated" ? change : changes[pairIdx]; + const expiredChange = change.action === "expired" ? change : changes[pairIdx]; + + result.push({ + action: "updated", + subscription: activatedChange.subscription, + purchase: activatedChange.purchase, + previous_attributes: expiredChange.previous_attributes, + item_changes: activatedChange.item_changes, + }); + } + + return result; +}; + +export const buildPlanChanges = ({ + autumnBillingPlan, +}: { + autumnBillingPlan: AutumnBillingPlan; +}): CustomerPlanChange[] => { + const changes: CustomerPlanChange[] = []; + + for (const cusProduct of autumnBillingPlan.insertCustomerProducts ?? []) { + const action = + cusProduct.status === CusProductStatus.Scheduled + ? "scheduled" + : "activated"; + changes.push({ + action, + ...toCustomerPlanSnapshot({ cusProduct }), + previous_attributes: null, + item_changes: [], + }); + } + + const updates = [ + ...(autumnBillingPlan.updateCustomerProduct + ? [autumnBillingPlan.updateCustomerProduct] + : []), + ...(autumnBillingPlan.updateCustomerProducts ?? []), + ]; + + for (const update of updates) { + const originalCusProduct = update.customerProduct; + const previousAttributes = buildPreviousAttributes({ + originalCusProduct, + updates: update.updates, + }); + + // Action is derived from the public lifecycle transition: + // non-active → "active" ⇒ "activated" (e.g. scheduled → active) + // anything → "expired" ⇒ "expired" + // else ⇒ "updated" + const beforePublic = cusProductStatusToPublicStatus( + originalCusProduct.status, + ); + const afterPublic = cusProductStatusToPublicStatus( + update.updates.status ?? originalCusProduct.status, + ); + + let action: CustomerPlanChange["action"]; + if (afterPublic === "expired") { + action = "expired"; + } else if (beforePublic !== "active" && afterPublic === "active") { + action = "activated"; + } else { + action = "updated"; + } + + changes.push({ + action, + ...toCustomerPlanSnapshot({ + cusProduct: originalCusProduct, + overrides: { + status: update.updates.status, + canceled_at: update.updates.canceled_at, + ended_at: update.updates.ended_at, + trial_ends_at: update.updates.trial_ends_at, + }, + }), + previous_attributes: previousAttributes, + item_changes: [], + }); + } + + for (const patch of autumnBillingPlan.patchCustomerProducts ?? []) { + changes.push({ + action: "updated", + ...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }), + previous_attributes: {}, + item_changes: buildPlanItemChanges({ + insertCustomerEntitlements: patch.insertCustomerEntitlements, + deleteCustomerEntitlements: patch.deleteCustomerEntitlements, + }), + }); + } + + return collapseSamePlanIdPairs(changes); +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts new file mode 100644 index 000000000..38776119a --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts @@ -0,0 +1,23 @@ +import type { + CustomerPlanItemChange, + FullCustomerEntitlement, +} from "@autumn/shared"; + +export const buildPlanItemChanges = ({ + insertCustomerEntitlements, + deleteCustomerEntitlements, +}: { + insertCustomerEntitlements?: FullCustomerEntitlement[]; + deleteCustomerEntitlements?: FullCustomerEntitlement[]; +}): CustomerPlanItemChange[] => { + const changes: CustomerPlanItemChange[] = []; + + for (const ent of insertCustomerEntitlements ?? []) { + changes.push({ action: "created", feature_id: ent.feature_id }); + } + for (const ent of deleteCustomerEntitlements ?? []) { + changes.push({ action: "deleted", feature_id: ent.feature_id }); + } + + return changes; +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts new file mode 100644 index 000000000..1ce7f8072 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts @@ -0,0 +1,65 @@ +import { + CusProductStatus, + type CustomerProductUpdateSchema, + type FullCusProduct, +} from "@autumn/shared"; +import type { z } from "zod/v4"; +import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping"; + +type CustomerProductUpdate = z.infer; + +const isPastDue = (status: CusProductStatus | undefined): boolean => + status === CusProductStatus.PastDue; + +export const buildPreviousAttributes = ({ + originalCusProduct, + updates, +}: { + originalCusProduct: FullCusProduct; + updates: CustomerProductUpdate["updates"]; +}): Record => { + const previous: Record = {}; + + // Surface-level `status` diff: only emit if the public status differs. + // (Trialing↔Active, PastDue↔Active both map to "active" publicly, so they + // don't show here — they show via past_due / trial_ends_at instead.) + if (updates.status !== undefined) { + const beforePublic = cusProductStatusToPublicStatus( + originalCusProduct.status, + ); + const afterPublic = cusProductStatusToPublicStatus(updates.status); + if (beforePublic !== afterPublic) { + previous.status = beforePublic; + } + + // past_due flag flip + const beforePastDue = isPastDue(originalCusProduct.status); + const afterPastDue = isPastDue(updates.status); + if (beforePastDue !== afterPastDue) { + previous.past_due = beforePastDue; + } + } + + const originalCanceledAt = originalCusProduct.canceled_at ?? null; + if ( + updates.canceled_at !== undefined && + updates.canceled_at !== originalCanceledAt + ) { + previous.canceled_at = originalCanceledAt; + } + + const originalEndedAt = originalCusProduct.ended_at ?? null; + if (updates.ended_at !== undefined && updates.ended_at !== originalEndedAt) { + previous.expires_at = originalEndedAt; + } + + const originalTrialEndsAt = originalCusProduct.trial_ends_at ?? null; + if ( + updates.trial_ends_at !== undefined && + updates.trial_ends_at !== originalTrialEndsAt + ) { + previous.trial_ends_at = originalTrialEndsAt; + } + + return previous; +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/cusProductStatusMapping.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/cusProductStatusMapping.ts new file mode 100644 index 000000000..87a398eca --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/cusProductStatusMapping.ts @@ -0,0 +1,23 @@ +import { CusProductStatus } from "@autumn/shared"; + +export type PublicLifecycleStatus = "active" | "scheduled" | "expired"; + +/** + * Maps internal `CusProductStatus` to the public lifecycle status surfaced on + * webhook payloads (and the public API). Trialing / PastDue / Paused are + * collapsed; the underlying state is conveyed via `past_due` / `trial_ends_at` + * fields rather than the status enum. + */ +export const cusProductStatusToPublicStatus = ( + status: CusProductStatus, +): PublicLifecycleStatus => { + switch (status) { + case CusProductStatus.Scheduled: + return "scheduled"; + case CusProductStatus.Expired: + case CusProductStatus.Paused: + return "expired"; + default: + return "active"; + } +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts new file mode 100644 index 000000000..5f4e6331c --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts @@ -0,0 +1,5 @@ +export * from "./buildBillingChangeResponse"; +export * from "./buildPlanChanges"; +export * from "./buildPlanItemChanges"; +export * from "./buildPreviousAttributes"; +export * from "./toCustomerPlanSnapshot"; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts new file mode 100644 index 000000000..6128a18cb --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts @@ -0,0 +1,99 @@ +import { + CusProductStatus, + type FullCusProduct, + isCustomerProductOneOff, + isCustomerProductTrialing, + type PurchaseSnapshot, + type PurchaseStatus, + type SubscriptionSnapshot, + type SubscriptionStatus, +} from "@autumn/shared"; +import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping"; + +const cusProductStatusToSubscriptionStatus = ( + status: CusProductStatus, +): SubscriptionStatus => cusProductStatusToPublicStatus(status); + +const cusProductStatusToPurchaseStatus = ( + status: CusProductStatus, +): PurchaseStatus => { + switch (status) { + case CusProductStatus.Scheduled: + return "scheduled"; + case CusProductStatus.Expired: + return "expired"; + default: + return "active"; + } +}; + +export type CustomerPlanSnapshotOverrides = Partial<{ + status: CusProductStatus; + canceled_at: number | null; + ended_at: number | null; + trial_ends_at: number | null; +}>; + +export type CustomerPlanSnapshotForChange = + | { subscription: SubscriptionSnapshot; purchase?: undefined } + | { subscription?: undefined; purchase: PurchaseSnapshot }; + +export const toCustomerPlanSnapshot = ({ + cusProduct, + overrides, +}: { + cusProduct: FullCusProduct; + overrides?: CustomerPlanSnapshotOverrides; +}): CustomerPlanSnapshotForChange => { + const status = overrides?.status ?? cusProduct.status; + const endedAt = + overrides?.ended_at !== undefined + ? overrides.ended_at + : (cusProduct.ended_at ?? null); + + if (isCustomerProductOneOff(cusProduct)) { + return { + purchase: { + plan_id: cusProduct.product_id, + status: cusProductStatusToPurchaseStatus(status), + expires_at: endedAt, + }, + }; + } + + const canceledAt = + overrides?.canceled_at !== undefined + ? overrides.canceled_at + : (cusProduct.canceled_at ?? null); + + // Apply overrides for trial_ends_at if provided, else read from cusProduct + // after substituting in any pending status. trial_ends_at on the public + // snapshot is only populated while actively trialing (mirrors getApiSubscription). + const rawTrialEndsAt = + overrides?.trial_ends_at !== undefined + ? overrides.trial_ends_at + : (cusProduct.trial_ends_at ?? null); + + const effectiveCusProductForTrialCheck: FullCusProduct = { + ...cusProduct, + status, + trial_ends_at: rawTrialEndsAt, + }; + const trialEndsAt = isCustomerProductTrialing(effectiveCusProductForTrialCheck) + ? rawTrialEndsAt + : null; + + return { + subscription: { + plan_id: cusProduct.product_id, + status: cusProductStatusToSubscriptionStatus(status), + past_due: status === CusProductStatus.PastDue, + started_at: cusProduct.starts_at ?? null, + canceled_at: canceledAt, + expires_at: endedAt, + trial_ends_at: trialEndsAt, + current_period_start: null, + current_period_end: null, + }, + }; +}; diff --git a/server/src/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.ts b/server/src/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.ts new file mode 100644 index 000000000..00487bae7 --- /dev/null +++ b/server/src/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.ts @@ -0,0 +1,64 @@ +/** + * Sends the `billing.updated` webhook with a freshly built BillingChangeResponse. + * + * Skips when: + * - `ctx.testOptions?.skipWebhooks` is set + * - the resulting response has no `plan_changes` + * + * Intended to be called fire-and-forget from emission sites: + * `void sendBillingUpdatedWebhook({ ctx, autumnBillingPlan, originalFullCustomer });` + * Errors are caught and logged internally. + */ + +import { + type AutumnBillingPlan, + type FullCustomer, + fullCustomerToTags, + WebhookEventType, +} from "@autumn/shared"; +import { sendSvixEvent } from "@/external/svix/svixHelpers.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; + +export const sendBillingUpdatedWebhook = async ({ + ctx, + autumnBillingPlan, + originalFullCustomer, + tags, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; + originalFullCustomer: FullCustomer; + tags?: string[]; +}): Promise => { + if (ctx.testOptions?.skipWebhooks) return; + + try { + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer, + autumnBillingPlan, + tags, + }); + + if (response.plan_changes.length === 0) return; + + // Svix message tags (separate from the payload `tags` field) — used + // for routing/filtering at the Svix dashboard level. Mirrors the + // pattern in sendProductsUpdated. + const svixTags = fullCustomerToTags({ fullCustomer: originalFullCustomer }); + + await sendSvixEvent({ + ctx, + eventType: WebhookEventType.BillingUpdated, + data: response, + tags: svixTags, + }); + + ctx.logger.info( + `[sendBillingUpdatedWebhook] Sent billing.updated for ${response.customer_id} (${response.plan_changes.length} changes${response.tags.length ? `, tags=${response.tags.join(",")}` : ""})`, + ); + } catch (error) { + ctx.logger.error(`[sendBillingUpdatedWebhook] Failed: ${error}`); + } +}; diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts index 88be62d1c..b35656c13 100644 --- a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts @@ -4,6 +4,7 @@ import { isUniqueConstraintError } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.js"; import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js"; import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext.js"; import { captureOrgEvent } from "@/utils/posthog.js"; @@ -95,6 +96,13 @@ export const executeAutumnCreateCustomerPlan = async ({ billingContext: context, }); + // Fire-and-forget: don't block customer creation on svix delivery + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan, + originalFullCustomer: context.fullCustomer, + }); + if (ctx.authType === AuthType.SecretKey) { await captureOrgEvent({ orgId: ctx.org.id, diff --git a/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts b/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts index b7d5e0365..e972997b7 100644 --- a/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts +++ b/server/src/internal/customers/cusProducts/actions/revertTrialExpiry.ts @@ -1,4 +1,5 @@ import { + type AutumnBillingPlan, CusProductStatus, customerProducts as customerProductsTable, type customerProducts, @@ -6,12 +7,18 @@ import { import { and, eq, type InferSelectModel } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook"; +import { CusService } from "@/internal/customers/CusService"; +import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; /** - * Handles revert trial expiry inside a transaction: expire the trial cusProduct - * and unpause the previous one atomically so we never leave a customer - * without an active plan. + * Handles revert trial expiry inside a transaction: expire the trial + * cusProduct and unpause the previous one atomically so we never leave a + * customer without an active plan. + * + * Emits the `billing.updated` webhook (tag: `trial_ended`) describing both + * the trial expiry and the restored previous plan. * * Returns true if handled, false to fall through to standard expiry. */ @@ -34,6 +41,25 @@ export const tryProcessRevertExpiry = async ({ return false; } + // Snapshot fullCustomer BEFORE the transaction so the webhook payload + // reflects pre-revert state in `previous_attributes`. RELEVANT_STATUSES + // is broadened with Paused so the previous (paused) cusProduct is + // visible — keeping the query narrow vs. ALL_STATUSES. + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + withSubs: true, + inStatuses: [...RELEVANT_STATUSES, CusProductStatus.Paused], + }); + + const trialFullCusProduct = fullCustomer.customer_products.find( + (cp) => cp.id === customerProduct.id, + ); + const previousFullCusProduct = fullCustomer.customer_products.find( + (cp) => cp.id === previousCusProductId, + ); + const now = Date.now(); await ctx.db.transaction(async (tx) => { const txDb = tx as unknown as DrizzleCli; @@ -60,5 +86,32 @@ export const tryProcessRevertExpiry = async ({ source: "productCron:revert", }); + // Emit billing.updated webhook (fire-and-forget) describing both the + // trial expiry and the restored previous plan. Skipped silently if we + // couldn't resolve either snapshot. + if (trialFullCusProduct && previousFullCusProduct) { + const autumnBillingPlan: AutumnBillingPlan = { + customerId: fullCustomer.id ?? fullCustomer.internal_id, + insertCustomerProducts: [], + updateCustomerProducts: [ + { + customerProduct: trialFullCusProduct, + updates: { status: CusProductStatus.Expired }, + }, + { + customerProduct: previousFullCusProduct, + updates: { status: CusProductStatus.Active }, + }, + ], + }; + + void sendBillingUpdatedWebhook({ + ctx, + autumnBillingPlan, + originalFullCustomer: fullCustomer, + tags: ["trial_ended"], + }); + } + return true; }; diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 0ce88ef2f..26738edc5 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -5,10 +5,11 @@ export const temp: TestGroup = { description: "failed retry tests", tier: "domain", paths: [ - "integration/billing/setup-payment/setup-payment-with-plan.test.ts", - "integration/crud/customers/customer-processors.test.ts", - "integration/crud/customers/get-customer-aggregated-balances.test.ts", - "integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts", - "integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-attach.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-multi-attach.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-create-schedule.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-update-subscription.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-updated.test.ts", + "integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-deleted.test.ts", ], }; diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-attach.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-attach.test.ts new file mode 100644 index 000000000..3de6dcff1 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-attach.test.ts @@ -0,0 +1,471 @@ +/** + * Integration tests for `billing.updated` webhook via the ATTACH V2 endpoint. + * + * Contract under test: + * Event type: billing.updated + * Payload shape (BillingChangeResponse): + * - object: "billing.updated" + * - customer_id: string + * - entity_id?: string | null + * - plan_changes: Array<{ + * action: "activated" | "scheduled" | "updated" | "expired", + * plan: { plan_id, status, started_at, canceled_at, expires_at, ... }, + * previous_attributes: Record | null, + * item_changes: Array<{ action, feature_id }>, + * }> + * + * Scenarios: + * A1: new customer, paid plan attach → one `activated` for pro + * A2: immediate upgrade pro → premium → `activated` for premium + `expired` for pro + * A3: scheduled downgrade premium → pro → `updated` for premium + `scheduled` for pro + * A4: cancel premium → free → `updated` for premium + `scheduled` for free + * E1: new entity attach → entity_id populated, one `activated` + * E2: entity upgrade → entity_id populated, `activated` + `expired` + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// A1: NEW PAID PLAN ATTACH +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: A1 new paid plan attach → activated")}`, async () => { + const customerId = "billing-updated-a1-new"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.customer_id).toBe(customerId); + + const activated = findChange(data.plan_changes, { + action: "activated", + planId: pro.id, + }); + expect(activated).toBeDefined(); + expect(activated?.previous_attributes).toBeNull(); + expect(activated?.subscription?.plan_id).toBe(pro.id); + expect(activated?.subscription?.status).toBe("active"); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// A2: IMMEDIATE UPGRADE PRO → PREMIUM +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: A2 immediate upgrade → activated + expired")}`, async () => { + const customerId = "billing-updated-a2-upgrade"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: premium.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "expired", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const activated = findChange(data.plan_changes, { + action: "activated", + planId: premium.id, + }); + expect(activated).toBeDefined(); + expect(activated?.previous_attributes).toBeNull(); + + const expired = findChange(data.plan_changes, { + action: "expired", + planId: pro.id, + }); + expect(expired).toBeDefined(); + expect(expired?.previous_attributes).toMatchObject({ status: "active" }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// A3: SCHEDULED DOWNGRADE PREMIUM → PRO +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: A3 scheduled downgrade → updated + scheduled")}`, async () => { + const customerId = "billing-updated-a3-downgrade"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "updated", + planId: premium.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "scheduled", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const updated = findChange(data.plan_changes, { + action: "updated", + planId: premium.id, + }); + expect(updated).toBeDefined(); + expect(updated?.previous_attributes).toMatchObject({ + canceled_at: null, + expires_at: null, + }); + + const scheduled = findChange(data.plan_changes, { + action: "scheduled", + planId: pro.id, + }); + expect(scheduled).toBeDefined(); + expect(scheduled?.subscription?.status).toBe("scheduled"); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// A4: CANCEL TO FREE PREMIUM → FREE +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: A4 cancel to free → updated + scheduled")}`, async () => { + const customerId = "billing-updated-a4-cancel"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [free, premium] }), + ], + actions: [s.attach({ productId: premium.id })], + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "updated", + planId: premium.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "scheduled", + planId: free.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const updated = findChange(data.plan_changes, { + action: "updated", + planId: premium.id, + }); + expect(updated).toBeDefined(); + expect(updated?.previous_attributes).toMatchObject({ + canceled_at: null, + expires_at: null, + }); + + const scheduled = findChange(data.plan_changes, { + action: "scheduled", + planId: free.id, + }); + expect(scheduled).toBeDefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// E1: ENTITY-LEVEL NEW ATTACH +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: E1 entity new attach → entity_id + activated")}`, async () => { + const customerId = "billing-updated-e1-entity-new"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const entityId = entities[0].id; + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entityId, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + payload.data?.entity_id === entityId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.entity_id).toBe(entityId); + + const activated = findChange(data.plan_changes, { + action: "activated", + planId: pro.id, + }); + expect(activated).toBeDefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// E2: ENTITY-LEVEL UPGRADE +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("billing.updated: E2 entity upgrade → entity_id + activated + expired")}`, async () => { + const customerId = "billing-updated-e2-entity-upgrade"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, premium] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: pro.id, entityIndex: 0 })], + }); + + const entityId = entities[0].id; + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entityId, + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + payload.data?.entity_id === entityId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: premium.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "expired", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.entity_id).toBe(entityId); + + const activated = findChange(data.plan_changes, { + action: "activated", + planId: premium.id, + }); + expect(activated).toBeDefined(); + + const expired = findChange(data.plan_changes, { + action: "expired", + planId: pro.id, + }); + expect(expired).toBeDefined(); + expect(expired?.previous_attributes).toMatchObject({ status: "active" }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CHECKOUT: ATTACH VIA STRIPE CHECKOUT (no payment method on file) +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("billing.updated: stripe checkout completion → activated")}`, async () => { + const customerId = "billing-updated-stripe-checkout"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro-checkout", items: [messagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, skipWebhooks: true }), // no payment method + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Attach returns a payment_url because there's no PM on file + const attachResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + }); + expect(attachResult.payment_url).toContain("checkout.stripe.com"); + + // Complete checkout in Stripe — triggers checkout.session.completed → + // handleCheckoutSessionMetadataV2 → executeBillingPlan → webhook fires + await completeStripeCheckoutForm({ url: attachResult.payment_url }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: pro.id, + }) !== undefined, + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const activated = findChange(result!.payload.data.plan_changes, { + action: "activated", + planId: pro.id, + }); + expect(activated?.subscription?.status).toBe("active"); + expect(activated?.previous_attributes).toBeNull(); +}); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-create-schedule.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-create-schedule.test.ts new file mode 100644 index 000000000..64e7599bb --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-create-schedule.test.ts @@ -0,0 +1,149 @@ +/** + * Integration test: `billing.updated` webhook fires for create-schedule. + * Immediate-phase plans show up as `activated`, future-phase plans as + * `scheduled`. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CreateScheduleParamsV0Input, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { ms } from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse & { tags?: string[] }; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test( + `${chalk.yellowBright("billing.updated: create-schedule with multi-plan phases → activated × 2 + scheduled × 2")}`, + async () => { + const customerId = "billing-updated-create-schedule"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addonNow = products.recurringAddOn({ + id: "addon-now", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + const addonLater = products.recurringAddOn({ + id: "addon-later", + items: [items.monthlyWords({ includedUsage: 50 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, addonNow, premium, addonLater] }), + ], + actions: [], + }); + + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }, { plan_id: addonNow.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: premium.id }, { plan_id: addonLater.id }], + }, + ], + }; + await autumnV1.billing.createSchedule(params); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: pro.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "activated", + planId: addonNow.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "scheduled", + planId: premium.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "scheduled", + planId: addonLater.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + // Immediate phase (now): both plans activated + for (const planId of [pro.id, addonNow.id]) { + const change = findChange(data.plan_changes, { + action: "activated", + planId, + }); + expect(change?.subscription?.status).toBe("active"); + } + + // Future phase (+30 days): both plans scheduled + for (const planId of [premium.id, addonLater.id]) { + const change = findChange(data.plan_changes, { + action: "scheduled", + planId, + }); + expect(change?.subscription?.status).toBe("scheduled"); + } + }, +); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-multi-attach.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-multi-attach.test.ts new file mode 100644 index 000000000..208b80094 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-multi-attach.test.ts @@ -0,0 +1,116 @@ +/** + * Integration test: `billing.updated` webhook fires for multi-attach, + * containing one `activated` plan_change per product attached in the call. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse & { tags?: string[] }; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test( + `${chalk.yellowBright("billing.updated: multi-attach two plans → activated for each")}`, + async () => { + const customerId = "billing-updated-multi-attach"; + const planA = products.pro({ + id: "plan-a", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const planB = products.base({ + id: "plan-b", + items: [items.monthlyUsers({ includedUsage: 10 }), items.monthlyPrice({ price: 30 })], + group: "group-b", + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [planA, planB] }), + ], + actions: [], + }); + + await autumnV1.billing.multiAttach({ + customer_id: customerId, + plans: [{ plan_id: planA.id }, { plan_id: planB.id }], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "activated", + planId: planA.id, + }) !== undefined && + findChange(payload.data.plan_changes, { + action: "activated", + planId: planB.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const activatedA = findChange(data.plan_changes, { + action: "activated", + planId: planA.id, + }); + expect(activatedA?.previous_attributes).toBeNull(); + expect(activatedA?.subscription?.status).toBe("active"); + + const activatedB = findChange(data.plan_changes, { + action: "activated", + planId: planB.id, + }); + expect(activatedB?.previous_attributes).toBeNull(); + expect(activatedB?.subscription?.status).toBe("active"); + }, +); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-product-cron.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-product-cron.test.ts new file mode 100644 index 000000000..2fa9d1e8d --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-product-cron.test.ts @@ -0,0 +1,155 @@ +/** + * Integration test: `billing.updated` webhook fires from the productCron + * when a trial expires. Tagged `trial_ended`. + * + * Setup: attach a paid product, then attach an enterprise product with a + * revert trial. Manually backdate `trial_ends_at` and invoke + * `runProductCron`. The cron picks up the trial row (matched on + * `on_trial_end = "revert"`), `tryProcessRevertExpiry` expires the trial + * and unpauses the prior plan, and `processExpiredTrialRow` fires the + * webhook tagged `trial_ended`. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + type AttachParamsV1Input, + type BillingChangeResponse, + type CustomerPlanChange, + customerProducts, + FreeTrialDuration, + type PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { runProductCron } from "@/cron/productCron/runProductCron"; +import { logger } from "@/external/logtail/logtailUtils"; +import { CusService } from "@/internal/customers/CusService"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse & { tags?: string[] }; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test( + `${chalk.yellowBright("billing.updated: productCron revert-trial expiry → trial_ended tag")}`, + async () => { + const customerId = "billing-updated-product-cron-revert"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro-cron", + items: [messagesItem, priceItem], + }); + const enterprise = products.base({ + id: "enterprise-cron", + items: [ + items.monthlyMessages({ includedUsage: 1000 }), + items.monthlyPrice({ price: 50 }), + ], + }); + + const { autumnV2, ctx: scenarioCtx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, enterprise] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Attach enterprise with a revert trial — pro gets paused, enterprise + // is now trialing with on_trial_end="revert". + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: enterprise.id, + redirect_mode: "if_required", + customize: { + free_trial: { + duration_length: 14, + duration_type: FreeTrialDuration.Day, + card_required: false, + on_end: "revert", + }, + }, + }; + await autumnV2.billing.attach(params); + + // Backdate trial_ends_at so the cron picks the enterprise row up. + const fullCustomer = await CusService.getFull({ + ctx: scenarioCtx, + idOrInternalId: customerId, + }); + const pastTrialEnd = Date.now() - 60_000; + await scenarioCtx.db + .update(customerProducts) + .set({ trial_ends_at: pastTrialEnd }) + .where( + eq(customerProducts.internal_customer_id, fullCustomer.internal_id), + ); + + await runProductCron({ ctx: { db: scenarioCtx.db, logger } }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + (payload.data?.tags ?? []).includes("trial_ended"), + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.tags).toContain("trial_ended"); + + // Enterprise (the trial) expires; pro (was paused) goes back to active. + const expired = findChange(data.plan_changes, { + action: "expired", + planId: enterprise.id, + }); + expect(expired).toBeDefined(); + + const restored = findChange(data.plan_changes, { + action: "activated", + planId: pro.id, + }); + expect(restored).toBeDefined(); + }, +); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-deleted.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-deleted.test.ts new file mode 100644 index 000000000..adb5eba72 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-deleted.test.ts @@ -0,0 +1,166 @@ +/** + * Integration test: `billing.updated` webhook fires when a Stripe + * subscription is canceled (subscription.deleted webhook). + * + * Setup: attach a paid plan, cancel the Stripe subscription directly. Our + * `handleStripeSubscriptionDeleted` handler expires the customer product and + * `emitBillingChangeWebhook` fires the webhook with an `expired` change. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId.js"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse & { tags?: string[] }; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test( + `${chalk.yellowBright("billing.updated: stripe subscription.deleted → expired change")}`, + async () => { + const customerId = "billing-updated-sub-deleted"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + const { ctx: scenarioCtx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const subscriptionId = await getSubscriptionId({ + ctx: scenarioCtx, + customerId, + productId: pro.id, + }); + + await scenarioCtx.stripeCli.subscriptions.cancel(subscriptionId); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data?.plan_changes, { + action: "expired", + planId: pro.id, + }) !== undefined, + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const expired = findChange(result!.payload.data.plan_changes, { + action: "expired", + planId: pro.id, + }); + expect(expired).toBeDefined(); + }, +); + +// When a customer "cancels to free" (downgrades a paid plan to a free +// default), Autumn schedules pro for expiry at period_end and free to start +// at the same moment. At period_end, Stripe cancels pro's subscription → +// `handleStripeSubscriptionDeleted` activates free and emits the webhook +// with pro expired + free activated. +test( + `${chalk.yellowBright("billing.updated: pro → free at period end → expired pro + activated free (via subscription.deleted)")}`, + async () => { + const customerId = "billing-updated-cancel-to-free"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [free, pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.attach({ productId: free.id }), // schedules cancel to free + s.advanceTestClock({ toNextInvoice: true }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data?.plan_changes, { + action: "expired", + planId: pro.id, + }) !== undefined && + findChange(payload.data?.plan_changes, { + action: "activated", + planId: free.id, + }) !== undefined, + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const expired = findChange(data.plan_changes, { + action: "expired", + planId: pro.id, + }); + expect(expired?.subscription?.status).toBe("expired"); + + const activated = findChange(data.plan_changes, { + action: "activated", + planId: free.id, + }); + expect(activated?.subscription?.status).toBe("active"); + }, +); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-updated.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-updated.test.ts new file mode 100644 index 000000000..fafe8cb59 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-subscription-updated.test.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for `billing.updated` webhooks emitted from Stripe's + * `customer.subscription.updated` flow — `handleStripeSubscriptionUpdated`. + * + * Scenarios covered: + * - Trial end: status flips out of trialing → tags `trial_ended` + * - Schedule phase change: scheduled phase activates → tags `phase_changed` + * - Past due: failed payment at renewal → updated change with `past_due` + * flipped in `previous_attributes` (no tag — structural signal) + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse & { tags?: string[] }; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TRIAL ENDED +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("billing.updated: trial end → tags includes trial_ended")}`, async () => { + const customerId = "billing-updated-trial-end"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const proTrial = products.proWithTrial({ + id: "pro", + items: [messagesItem], + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 16 }), // past 14-day trial + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + (payload.data?.tags ?? []).includes("trial_ended"), + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.tags).toContain("trial_ended"); + + const updated = findChange(data.plan_changes, { + action: "updated", + planId: proTrial.id, + }); + expect(updated).toBeDefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// SCHEDULE PHASE CHANGED +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("billing.updated: schedule phase change → tags includes phase_changed")}`, async () => { + const customerId = "billing-updated-phase-change"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + const premium = products.premium({ id: "premium", items: [messagesItem] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro, premium] }), + ], + actions: [ + s.attach({ productId: premium.id }), + // Schedule downgrade: pro takes over at premium's period end. + s.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + (payload.data?.tags ?? []).includes("phase_changed"), + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + expect(data.tags).toContain("phase_changed"); + + // Old premium expires; pro (was scheduled) is now activated. + const expired = findChange(data.plan_changes, { + action: "expired", + planId: premium.id, + }); + const activated = findChange(data.plan_changes, { + action: "activated", + planId: pro.id, + }); + expect(expired || activated).toBeDefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// PAST DUE +// ═══════════════════════════════════════════════════════════════════════════════ + +test(`${chalk.yellowBright("billing.updated: customer enters past_due → updated with past_due flip")}`, async () => { + const customerId = "billing-updated-past-due"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [ + s.attach({ productId: pro.id }), + s.removePaymentMethod(), + s.attachPaymentMethod({ type: "fail" }), + s.advanceTestClock({ toNextInvoice: true }), + ], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data?.plan_changes, { + action: "updated", + planId: pro.id, + })?.subscription?.past_due === true, + timeoutMs: 30000, + }); + + expect(result).not.toBeNull(); + const updated = findChange(result!.payload.data.plan_changes, { + action: "updated", + planId: pro.id, + }); + expect(updated?.subscription?.past_due).toBe(true); + expect(updated?.previous_attributes).toMatchObject({ past_due: false }); +}); diff --git a/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-update-subscription.test.ts b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-update-subscription.test.ts new file mode 100644 index 000000000..113b4656f --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/billing-updated/billing-updated-update-subscription.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for `billing.updated` webhook via update-subscription endpoint. + * + * Contract under test: + * Event type: billing.updated + * Scenario U1: update product items (increase included usage) → one `updated` for pro + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { + getTestSvixAppId, + setupWebhookTest, + type WebhookTestSetup, + waitForWebhook, +} from "@tests/integration/utils/svixWebhookTestUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type BillingUpdatedPayload = { + type: string; + data: BillingChangeResponse; +}; + +const findChange = ( + plan_changes: CustomerPlanChange[] | undefined, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + plan_changes?.find( + (change) => + change.action === action && + (change.subscription?.plan_id ?? change.purchase?.plan_id) === planId, + ); + +let webhook: WebhookTestSetup; +let playToken: string; + +beforeAll(async () => { + const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config }); + webhook = await setupWebhookTest({ + appId, + filterTypes: ["billing.updated"], + }); + playToken = webhook.playToken; +}); + +afterAll(async () => { + await webhook?.cleanup(); +}); + +test.concurrent(`${chalk.yellowBright("billing.updated: U1 update items → updated")}`, async () => { + const customerId = "billing-updated-u1-update-items"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const newMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + items: [newMessagesItem, priceItem], + }); + + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "billing.updated" && + payload.data?.customer_id === customerId && + findChange(payload.data.plan_changes, { + action: "updated", + planId: pro.id, + }) !== undefined, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + const { data } = result!.payload; + + const updated = findChange(data.plan_changes, { + action: "updated", + planId: pro.id, + }); + expect(updated).toBeDefined(); + expect(updated?.subscription?.plan_id).toBe(pro.id); +}); diff --git a/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts b/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts index 9f1040336..c2d08cd33 100644 --- a/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts +++ b/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts @@ -23,7 +23,32 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import type { ApiCustomerV3, ApiEntityV0, ApiProduct } from "@autumn/shared"; import chalk from "chalk"; -import { Svix } from "svix"; +import { type MessageOut, Svix } from "svix"; + +/** + * `svix.message.get` can return 404 briefly after delivery while the message + * is still being indexed. Retry with a short backoff so we tolerate that + * indexing lag without flaking. + */ +const getSvixMessageWithRetry = async ( + svix: Svix, + appId: string, + messageId: string, + { retries = 5, delayMs = 500 }: { retries?: number; delayMs?: number } = {}, +): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < retries; attempt++) { + try { + return await svix.message.get(appId, messageId); + } catch (error) { + lastError = error; + const code = (error as { code?: number })?.code; + if (code !== 404) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw lastError; +}; import { getTestSvixAppId, setupWebhookTest, @@ -110,7 +135,7 @@ test.concurrent( expect(svixId).toBeDefined(); // ── Contract assertion 1: MessageOut.tags contains customer_id tag ─────── - const message = await svix.message.get(appId, svixId); + const message = await getSvixMessageWithRetry(svix, appId, svixId); const customerTag = `customer_id.${customerId}`; expect(message.tags).toContain(customerTag); @@ -161,7 +186,7 @@ test.concurrent( expect(svixId).toBeDefined(); // ── Contract assertion: tags include BOTH customer_id and entity_id ────── - const message = await svix.message.get(appId, svixId); + const message = await getSvixMessageWithRetry(svix, appId, svixId); const customerTag = `customer_id.${customerId}`; const entityTag = `entity_id.${entityId}`; expect(message.tags).toContain(customerTag); diff --git a/server/tests/integration/utils/svixWebhookTestUtils.ts b/server/tests/integration/utils/svixWebhookTestUtils.ts index d77a64006..2bea52fea 100644 --- a/server/tests/integration/utils/svixWebhookTestUtils.ts +++ b/server/tests/integration/utils/svixWebhookTestUtils.ts @@ -86,15 +86,23 @@ export const parseEventBody = (event: SvixPlayEvent): T => { return JSON.parse(decoded) as T; }; -/** Poll Svix Play until a webhook matching `predicate` appears, or timeout. */ +/** + * Poll Svix Play until a webhook matching `predicate` appears, or timeout. + * + * When `logWebhook` is true (default), the matched payload is pretty-printed + * to stdout — useful for eyeballing webhook shapes during local test runs. + * Set to false to suppress (e.g., in CI). + */ export const waitForWebhook = async ({ token, predicate, timeoutMs = 10000, + logWebhook = true, }: { token: string; predicate: (payload: T) => boolean; timeoutMs?: number; + logWebhook?: boolean; }): Promise<{ event: SvixPlayEvent; payload: T } | null> => { const startTime = Date.now(); @@ -104,7 +112,13 @@ export const waitForWebhook = async ({ for (const event of history.data) { try { const payload = parseEventBody(event); - if (predicate(payload)) return { event, payload }; + if (predicate(payload)) { + if (logWebhook) { + process.stdout.write("\n── webhook ────────────────────────\n"); + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } + return { event, payload }; + } } catch { // Skip events that can't be parsed } @@ -116,6 +130,29 @@ export const waitForWebhook = async ({ return null; }; +// ─── Event Type Registration ─────────────────────────────────────────────── + +/** + * Idempotently registers an event type. Svix requires event types referenced + * in `filterTypes` to be pre-registered at the org level; without this the + * endpoint.create call fails with HTTP 422. + * + * Catches "already exists" (409) errors so this can be called every test run. + */ +export const ensureSvixEventType = async (name: string): Promise => { + const svix = getSvixClient(); + try { + await svix.eventType.create({ + name, + description: `Auto-registered by integration tests: ${name}`, + }); + } catch (error) { + const status = (error as { code?: number })?.code; + if (status === 409 || status === 400) return; + throw error; + } +}; + // ─── Endpoint Lifecycle ───────────────────────────────────────────────────── export const createWebhookTestEndpoint = async ({ @@ -174,6 +211,12 @@ export const setupWebhookTest = async ({ appId: string; filterTypes: string[]; }): Promise => { + // Ensure every filter type is registered in Svix before creating the + // endpoint, otherwise endpoint.create fails with HTTP 422. + for (const eventType of filterTypes) { + await ensureSvixEventType(eventType); + } + const playToken = await generatePlayToken(); console.log(`Generated Svix Play token: ${playToken}`); diff --git a/server/tests/unit/billing/billing-change-response/attach.test.ts b/server/tests/unit/billing/billing-change-response/attach.test.ts new file mode 100644 index 000000000..722af6c0c --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/attach.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — attach", () => { + test("new customer, free plan attach", () => { + const free = makeFullCusProduct({ planId: "free", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer(), + autumnBillingPlan: makeAutumnBillingPlan({ inserts: [free] }), + }); + logChangeResponse("attach / new customer, free plan", response); + + expectBillingChangeResponse(response, { + customerId: "cus_test", + activated: ["free"], + }); + expectPlanChange(findPlanChange(response, { action: "activated", planId: "free" }), { + action: "activated", + planId: "free", + previousAttributes: null, + itemChanges: [], + }); + }); + + test("new customer, paid plan attach", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer(), + autumnBillingPlan: makeAutumnBillingPlan({ inserts: [pro] }), + }); + logChangeResponse("attach / new customer, paid plan", response); + + expectBillingChangeResponse(response, { activated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "activated", planId: "pro" }), { + action: "activated", + planId: "pro", + previousAttributes: null, + }); + }); + + test("immediate upgrade (free → pro)", () => { + const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 }); + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [free] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [pro], + update: makeUpdate({ + customerProduct: free, + updates: { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }, + }), + }), + }); + logChangeResponse("attach / immediate upgrade (free → pro)", response); + + expectBillingChangeResponse(response, { + expired: ["free"], + activated: ["pro"], + }); + expectPlanChange(findPlanChange(response, { action: "expired", planId: "free" }), { + action: "expired", + planId: "free", + previousAttributes: { + status: CusProductStatus.Active, + canceled_at: null, + expires_at: null, + }, + }); + expectPlanChange(findPlanChange(response, { action: "activated", planId: "pro" }), { + action: "activated", + planId: "pro", + previousAttributes: null, + }); + }); + + test("upgrade that also clears an existing scheduled downgrade", () => { + const business = makeFullCusProduct({ + planId: "business", + startedAt: NOW - 1000, + }); + const scheduledPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + id: "cp_pro_scheduled", + }); + const premium = makeFullCusProduct({ planId: "premium", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [business, scheduledPro], + }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [premium], + update: makeUpdate({ + customerProduct: business, + updates: { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }, + }), + deleteOne: scheduledPro, + }), + }); + logChangeResponse( + "attach / upgrade clears existing scheduled downgrade", + response, + ); + + // Deleted (scheduled) products are intentionally ignored — they never + // went live and aren't a customer-facing lifecycle event. + expectBillingChangeResponse(response, { + expired: ["business"], + activated: ["premium"], + }); + expect( + findPlanChange(response, { action: "expired", planId: "pro" }), + ).toBeUndefined(); + }); + + test("scheduled downgrade via starts_at", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + const scheduledFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [scheduledFree], + update: makeUpdate({ + customerProduct: pro, + updates: { + canceled: true, + canceled_at: NOW, + ended_at: PERIOD_END, + }, + }), + }), + }); + logChangeResponse("attach / scheduled downgrade (starts_at)", response); + + expectBillingChangeResponse(response, { + updated: ["pro"], + scheduled: ["free"], + }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + previousAttributes: { canceled_at: null, expires_at: null }, + }); + const scheduled = findPlanChange(response, { + action: "scheduled", + planId: "free", + }); + expect(scheduled?.subscription?.status).toBe("scheduled"); + }); + + test("attach addon (no current product mutated)", () => { + const base = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + const addon = makeFullCusProduct({ planId: "seats_addon", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [base] }), + autumnBillingPlan: makeAutumnBillingPlan({ inserts: [addon] }), + }); + logChangeResponse("attach / addon", response); + + expectBillingChangeResponse(response, { activated: ["seats_addon"] }); + }); + + test("trial revert: pause current and attach trial", () => { + const base = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW - 1000, + }); + const trialProduct = makeFullCusProduct({ + planId: "premium", + status: CusProductStatus.Trialing, + startedAt: NOW, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [base] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [trialProduct], + update: makeUpdate({ + customerProduct: base, + updates: { status: CusProductStatus.Paused }, + }), + }), + }); + logChangeResponse("attach / trial revert (pause current)", response); + + // Paused internally maps to "expired" in the public lifecycle — + // from a consumer's perspective the base plan is no longer in effect, + // so the action is `expired` (not `updated`). + expectBillingChangeResponse(response, { + expired: ["pro"], + activated: ["premium"], + }); + const expired = findPlanChange(response, { + action: "expired", + planId: "pro", + }); + expectPlanChange(expired, { + action: "expired", + planId: "pro", + previousAttributes: { status: "active" }, + }); + expect(expired?.subscription?.status).toBe("expired"); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/create-schedule.test.ts b/server/tests/unit/billing/billing-change-response/create-schedule.test.ts new file mode 100644 index 000000000..ab0185306 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/create-schedule.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PHASE_TWO = NOW + 30 * 24 * 60 * 60 * 1000; +const PHASE_THREE = NOW + 60 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — createSchedule", () => { + test("multi-phase schedule replacing current product", () => { + const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 }); + const pro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + }); + const premiumPhase = makeFullCusProduct({ + planId: "premium", + status: CusProductStatus.Scheduled, + startedAt: PHASE_TWO, + }); + const enterprisePhase = makeFullCusProduct({ + planId: "enterprise", + status: CusProductStatus.Scheduled, + startedAt: PHASE_THREE, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [free] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [pro, premiumPhase, enterprisePhase], + update: makeUpdate({ + customerProduct: free, + updates: { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }, + }), + }), + }); + logChangeResponse("createSchedule / multi-phase replacing current", response); + + expectBillingChangeResponse(response, { + expired: ["free"], + activated: ["pro"], + scheduled: ["premium", "enterprise"], + }); + expect( + findPlanChange(response, { action: "activated", planId: "pro" })?.subscription + ?.status, + ).toBe("active"); + expect( + findPlanChange(response, { action: "scheduled", planId: "premium" })?.subscription + ?.status, + ).toBe("scheduled"); + expect( + findPlanChange(response, { action: "scheduled", planId: "enterprise" }) + ?.subscription?.status, + ).toBe("scheduled"); + }); + + test("schedule overrides existing scheduled products", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + const oldScheduled = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PHASE_TWO, + }); + const newScheduledPremium = makeFullCusProduct({ + planId: "premium", + status: CusProductStatus.Scheduled, + startedAt: PHASE_TWO, + }); + const newScheduledEnterprise = makeFullCusProduct({ + planId: "enterprise", + status: CusProductStatus.Scheduled, + startedAt: PHASE_THREE, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [pro, oldScheduled], + }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newScheduledPremium, newScheduledEnterprise], + deletes: [oldScheduled], + }), + }); + logChangeResponse( + "createSchedule / schedule overrides existing scheduled", + response, + ); + + // The old scheduled product is deleted, not expired — deletes are + // intentionally skipped in v1. + expectBillingChangeResponse(response, { + scheduled: ["premium", "enterprise"], + }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/event-context-adapter.test.ts b/server/tests/unit/billing/billing-change-response/event-context-adapter.test.ts new file mode 100644 index 000000000..e46ad77cd --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/event-context-adapter.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus, type InsertCustomerProduct } from "@autumn/shared"; +import { eventContextToAutumnBillingPlan } from "@/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan"; +import type { StripeSubscriptionUpdatedContext } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +const makeUpdatedContext = ({ + inserted = [], + updated = [], + deleted = [], +}: { + inserted?: StripeSubscriptionUpdatedContext["insertedCustomerProducts"]; + updated?: StripeSubscriptionUpdatedContext["updatedCustomerProducts"]; + deleted?: StripeSubscriptionUpdatedContext["deletedCustomerProducts"]; +} = {}): StripeSubscriptionUpdatedContext => { + return { + fullCustomer: makeFullCustomer(), + customerProducts: [], + nowMs: NOW, + stripeSubscription: + {} as StripeSubscriptionUpdatedContext["stripeSubscription"], + previousAttributes: {}, + insertedCustomerProducts: inserted, + updatedCustomerProducts: updated, + deletedCustomerProducts: deleted, + billingChangeTags: new Set(), + }; +}; + +const updateOf = ( + customerProduct: StripeSubscriptionUpdatedContext["updatedCustomerProducts"][number]["customerProduct"], + updates: Partial, +): StripeSubscriptionUpdatedContext["updatedCustomerProducts"][number] => ({ + customerProduct, + updates, +}); + +describe("eventContextToAutumnBillingPlan + buildBillingChangeResponse", () => { + test("empty context produces an empty response", () => { + const eventContext = makeUpdatedContext(); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / empty", response); + + expectBillingChangeResponse(response, {}); + expect(response.plan_changes).toEqual([]); + }); + + test("inserted active product → activated change", () => { + const newPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + }); + const eventContext = makeUpdatedContext({ inserted: [newPro] }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / inserted active", response); + + expectBillingChangeResponse(response, { activated: ["pro"] }); + expectPlanChange( + findPlanChange(response, { action: "activated", planId: "pro" }), + { + action: "activated", + planId: "pro", + previousAttributes: null, + }, + ); + }); + + test("inserted scheduled product → scheduled change", () => { + const futureFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + const eventContext = makeUpdatedContext({ inserted: [futureFree] }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / inserted scheduled", response); + + expectBillingChangeResponse(response, { scheduled: ["free"] }); + }); + + test("updated product (status → past_due) → updated change with previous_attributes", () => { + const business = makeFullCusProduct({ + planId: "business", + status: CusProductStatus.Active, + startedAt: NOW - 1000, + }); + const eventContext = makeUpdatedContext({ + updated: [updateOf(business, { status: CusProductStatus.PastDue })], + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / updated to past_due", response); + + expectBillingChangeResponse(response, { updated: ["business"] }); + // Active → PastDue: public `status` stays "active" both before and after + // (past_due is not a public status value); the flip is conveyed via the + // `past_due` flag in previous_attributes. + expectPlanChange( + findPlanChange(response, { action: "updated", planId: "business" }), + { + action: "updated", + planId: "business", + previousAttributes: { past_due: false }, + }, + ); + }); + + test("updated product with status=Expired → expired change", () => { + const pro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + }); + const eventContext = makeUpdatedContext({ + updated: [ + updateOf(pro, { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }), + ], + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / updated to expired", response); + + expectBillingChangeResponse(response, { expired: ["pro"] }); + expectPlanChange( + findPlanChange(response, { action: "expired", planId: "pro" }), + { + action: "expired", + planId: "pro", + previousAttributes: { + status: CusProductStatus.Active, + canceled_at: null, + expires_at: null, + }, + }, + ); + }); + + test("deleted products are ignored", () => { + const scheduledFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + const eventContext = makeUpdatedContext({ deleted: [scheduledFree] }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / deleted ignored", response); + + expectBillingChangeResponse(response, {}); + expect(response.plan_changes).toEqual([]); + }); + + test("end-to-end schedule phase change (old expires, new activates)", () => { + const oldFree = makeFullCusProduct({ + planId: "free", + startedAt: NOW - 30 * 24 * 60 * 60 * 1000, + }); + const newPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + }); + const eventContext = makeUpdatedContext({ + updated: [ + updateOf(oldFree, { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }), + ], + inserted: [newPro], + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / schedule phase change", response); + + expectBillingChangeResponse(response, { + expired: ["free"], + activated: ["pro"], + }); + }); + + test("end-to-end cancel-at-period-end via webhook (canceled_at + ended_at set)", () => { + const business = makeFullCusProduct({ + planId: "business", + startedAt: NOW - 1000, + }); + const eventContext = makeUpdatedContext({ + updated: [ + updateOf(business, { + canceled: true, + canceled_at: NOW, + ended_at: PERIOD_END, + }), + ], + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: eventContext.fullCustomer, + autumnBillingPlan: eventContextToAutumnBillingPlan(eventContext), + }); + logChangeResponse("event-context / cancel at period end", response); + + expectBillingChangeResponse(response, { updated: ["business"] }); + expectPlanChange( + findPlanChange(response, { action: "updated", planId: "business" }), + { + action: "updated", + planId: "business", + previousAttributes: { canceled_at: null, expires_at: null }, + }, + ); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts new file mode 100644 index 000000000..4b4f971f9 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts @@ -0,0 +1,101 @@ +import type { + BillingChangeResponse, + CustomerPlanChange, + PlanChangeAction, +} from "@autumn/shared"; +import { expect } from "bun:test"; + +/** + * Returns the plan_id of a change regardless of whether it carries a + * `subscription` or a `purchase` snapshot. + */ +export const getChangePlanId = ( + change: CustomerPlanChange, +): string | undefined => + change.subscription?.plan_id ?? change.purchase?.plan_id; + +export const findPlanChange = ( + response: BillingChangeResponse, + { action, planId }: { action: PlanChangeAction; planId: string }, +): CustomerPlanChange | undefined => + response.plan_changes.find( + (change) => change.action === action && getChangePlanId(change) === planId, + ); + +export const expectPlanChange = ( + change: CustomerPlanChange | undefined, + { + action, + planId, + previousAttributes, + itemChanges, + }: { + action: PlanChangeAction; + planId: string; + previousAttributes?: Record | null; + itemChanges?: Array<{ action: "created" | "deleted"; feature_id: string }>; + }, +): CustomerPlanChange => { + expect(change, `expected ${action} change for plan ${planId}`).toBeDefined(); + const resolved = change as CustomerPlanChange; + expect(resolved.action).toBe(action); + expect(getChangePlanId(resolved)).toBe(planId); + + if (previousAttributes === null) { + expect(resolved.previous_attributes).toBeNull(); + } else if (previousAttributes !== undefined) { + expect(resolved.previous_attributes).not.toBeNull(); + for (const [key, value] of Object.entries(previousAttributes)) { + expect( + resolved.previous_attributes, + `previous_attributes.${key} mismatch`, + ).toMatchObject({ [key]: value }); + } + } + + if (itemChanges !== undefined) { + expect(resolved.item_changes).toEqual(itemChanges); + } + + return resolved; +}; + +export const expectBillingChangeResponse = ( + response: BillingChangeResponse, + { + customerId, + activated = [], + scheduled = [], + updated = [], + expired = [], + tags, + }: { + customerId?: string; + activated?: string[]; + scheduled?: string[]; + updated?: string[]; + expired?: string[]; + tags?: string[]; + }, +): void => { + if (customerId !== undefined) { + expect(response.customer_id).toBe(customerId); + } + + const byAction = (action: PlanChangeAction) => + response.plan_changes + .filter((change) => change.action === action) + .map((change) => getChangePlanId(change) ?? "") + .sort(); + + expect(byAction("activated")).toEqual([...activated].sort()); + expect(byAction("scheduled")).toEqual([...scheduled].sort()); + expect(byAction("updated")).toEqual([...updated].sort()); + expect(byAction("expired")).toEqual([...expired].sort()); + + if (tags !== undefined) { + expect( + (response as BillingChangeResponse & { tags?: string[] }).tags, + ).toEqual(tags); + } +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts b/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts new file mode 100644 index 000000000..137d676fa --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts @@ -0,0 +1,13 @@ +import type { BillingChangeResponse } from "@autumn/shared"; + +const isEnabled = (): boolean => process.env.PRINT_BILLING_CHANGES === "1"; + +export const logChangeResponse = ( + label: string, + response: BillingChangeResponse, +): void => { + if (!isEnabled()) return; + const divider = "─".repeat(Math.max(8, 60 - label.length)); + process.stdout.write(`\n── ${label} ${divider}\n`); + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts new file mode 100644 index 000000000..be450cf93 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts @@ -0,0 +1,73 @@ +import type { + AutumnBillingPlan, + CustomerProductUpdateSchema, + FullCusProduct, + FullCustomerEntitlement, + PatchCustomerProductSchema, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +type CustomerProductUpdate = z.infer; +type PatchCustomerProduct = z.infer; + +export const makeAutumnBillingPlan = ({ + inserts = [], + update, + updates, + deleteOne, + deletes, + patches, +}: { + inserts?: FullCusProduct[]; + update?: CustomerProductUpdate; + updates?: CustomerProductUpdate[]; + deleteOne?: FullCusProduct; + deletes?: FullCusProduct[]; + patches?: PatchCustomerProduct[]; +} = {}): AutumnBillingPlan => { + return { + customerId: "cus_test", + insertCustomerProducts: inserts, + updateCustomerProduct: update, + updateCustomerProducts: updates, + deleteCustomerProduct: deleteOne, + deleteCustomerProducts: deletes, + patchCustomerProducts: patches, + } as AutumnBillingPlan; +}; + +export const makeUpdate = ({ + customerProduct, + updates, +}: { + customerProduct: FullCusProduct; + updates: CustomerProductUpdate["updates"]; +}): CustomerProductUpdate => ({ customerProduct, updates }); + +export const makePatch = ({ + customerProduct, + insertEntitlements = [], + deleteEntitlements = [], +}: { + customerProduct: FullCusProduct; + insertEntitlements?: FullCustomerEntitlement[]; + deleteEntitlements?: FullCustomerEntitlement[]; +}): PatchCustomerProduct => + ({ + customerProduct, + insertCustomerEntitlements: insertEntitlements, + deleteCustomerEntitlements: deleteEntitlements, + insertCustomerPrices: [], + deleteCustomerPrices: [], + }) as PatchCustomerProduct; + +export const makeCustomerEntitlement = ({ + featureId, +}: { + featureId: string; +}): FullCustomerEntitlement => + ({ + id: `cusEnt_${featureId}`, + feature_id: featureId, + internal_feature_id: `internal_${featureId}`, + }) as unknown as FullCustomerEntitlement; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts b/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts new file mode 100644 index 000000000..7cbfa6d0f --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts @@ -0,0 +1,45 @@ +import { + CollectionMethod, + CusProductStatus, + type FullCusProduct, +} from "@autumn/shared"; + +export const makeFullCusProduct = ({ + planId, + status = CusProductStatus.Active, + startedAt, + canceledAt = null, + endedAt = null, + id, +}: { + planId: string; + status?: CusProductStatus; + startedAt?: number; + canceledAt?: number | null; + endedAt?: number | null; + id?: string; +}): FullCusProduct => { + return { + id: id ?? `cp_${planId}`, + internal_product_id: `internal_${planId}`, + product_id: planId, + internal_customer_id: "internal_cus_test", + customer_id: "cus_test", + created_at: 1_700_000_000_000, + updated_at: null, + status, + canceled: canceledAt !== null, + starts_at: startedAt ?? 1_700_000_000_000, + canceled_at: canceledAt, + ended_at: endedAt, + options: [], + collection_method: CollectionMethod.ChargeAutomatically, + quantity: 1, + api_semver: null, + is_custom: false, + external_id: null, + customer_prices: [], + customer_entitlements: [], + product: { id: planId, name: planId } as FullCusProduct["product"], + } as unknown as FullCusProduct; +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts b/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts new file mode 100644 index 000000000..f6831b731 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts @@ -0,0 +1,20 @@ +import { AppEnv, type FullCusProduct, type FullCustomer } from "@autumn/shared"; + +export const makeFullCustomer = ({ + id = "cus_test", + customerProducts = [], +}: { + id?: string; + customerProducts?: FullCusProduct[]; +} = {}): FullCustomer => { + return { + id, + internal_id: `internal_${id}`, + org_id: "org_test", + created_at: 1_700_000_000_000, + env: AppEnv.Sandbox, + processor: null, + customer_products: customerProducts, + entities: [], + } as unknown as FullCustomer; +}; diff --git a/server/tests/unit/billing/billing-change-response/migrate.test.ts b/server/tests/unit/billing/billing-change-response/migrate.test.ts new file mode 100644 index 000000000..1f7f172d5 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/migrate.test.ts @@ -0,0 +1,89 @@ +import { describe, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeCustomerEntitlement, + makePatch, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — migrate", () => { + test("migrate via update plan path (replace product)", () => { + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + }); + const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + update: makeUpdate({ + customerProduct: oldPro, + updates: { status: CusProductStatus.Expired }, + }), + }), + }); + logChangeResponse("migrate / update plan path", response); + + expectBillingChangeResponse(response, { + expired: ["pro"], + activated: ["pro_v2"], + }); + expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), { + action: "expired", + planId: "pro", + previousAttributes: { status: CusProductStatus.Active }, + }); + }); + + test("migrate via patch items (carry rollover)", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + patches: [ + makePatch({ + customerProduct: pro, + insertEntitlements: [ + makeCustomerEntitlement({ featureId: "new_feature_x" }), + makeCustomerEntitlement({ featureId: "new_feature_y" }), + ], + deleteEntitlements: [ + makeCustomerEntitlement({ featureId: "old_feature" }), + ], + }), + ], + }), + }); + logChangeResponse("migrate / patch items (carry rollover)", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + itemChanges: [ + { action: "created", feature_id: "new_feature_x" }, + { action: "created", feature_id: "new_feature_y" }, + { action: "deleted", feature_id: "old_feature" }, + ], + }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/multi-attach.test.ts b/server/tests/unit/billing/billing-change-response/multi-attach.test.ts new file mode 100644 index 000000000..ec3c557ad --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/multi-attach.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — multiAttach", () => { + test("multiple inserts, no current products", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW }); + const seatsAddon = makeFullCusProduct({ + planId: "seats_addon", + startedAt: NOW, + }); + const storageAddon = makeFullCusProduct({ + planId: "storage_addon", + startedAt: NOW, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer(), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [pro, seatsAddon, storageAddon], + }), + }); + logChangeResponse("multiAttach / multiple inserts no current", response); + + expectBillingChangeResponse(response, { + activated: ["pro", "seats_addon", "storage_addon"], + }); + }); + + test("multi-insert with one transitioning current product", () => { + const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 }); + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW }); + const addon = makeFullCusProduct({ + planId: "seats_addon", + startedAt: NOW, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [free] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [pro, addon], + update: makeUpdate({ + customerProduct: free, + updates: { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }, + }), + }), + }); + logChangeResponse( + "multiAttach / multi-insert with one transition", + response, + ); + + expectBillingChangeResponse(response, { + expired: ["free"], + activated: ["pro", "seats_addon"], + }); + }); + + test("mixed statuses across inserts (active + scheduled)", () => { + const pro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + }); + const scheduledAddon = makeFullCusProduct({ + planId: "seats_addon", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer(), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [pro, scheduledAddon], + }), + }); + logChangeResponse("multiAttach / mixed statuses", response); + + expectBillingChangeResponse(response, { + activated: ["pro"], + scheduled: ["seats_addon"], + }); + expect( + findPlanChange(response, { action: "activated", planId: "pro" }) + ?.subscription?.status, + ).toBe("active"); + expect( + findPlanChange(response, { + action: "scheduled", + planId: "seats_addon", + })?.subscription?.status, + ).toBe("scheduled"); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/restore.test.ts b/server/tests/unit/billing/billing-change-response/restore.test.ts new file mode 100644 index 000000000..e0ad12966 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/restore.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — restore", () => { + test("restore single canceled product", () => { + const canceledPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + canceledAt: NOW - 500, + endedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [canceledPro], + }), + autumnBillingPlan: makeAutumnBillingPlan({ + update: makeUpdate({ + customerProduct: canceledPro, + updates: { + canceled: false, + canceled_at: null, + ended_at: null, + }, + }), + }), + }); + logChangeResponse("restore / single canceled product", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + const updated = findPlanChange(response, { + action: "updated", + planId: "pro", + }); + expectPlanChange(updated, { + action: "updated", + planId: "pro", + previousAttributes: { + canceled_at: NOW - 500, + expires_at: PERIOD_END, + }, + }); + expect(updated?.subscription?.canceled_at).toBeNull(); + expect(updated?.subscription?.expires_at).toBeNull(); + }); + + test("restore multiple via updateCustomerProducts array", () => { + const proBase = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + canceledAt: NOW - 500, + endedAt: PERIOD_END, + }); + const addon = makeFullCusProduct({ + planId: "seats_addon", + startedAt: NOW - 1000, + canceledAt: NOW - 500, + endedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [proBase, addon], + }), + autumnBillingPlan: makeAutumnBillingPlan({ + updates: [ + makeUpdate({ + customerProduct: proBase, + updates: { canceled: false, canceled_at: null, ended_at: null }, + }), + makeUpdate({ + customerProduct: addon, + updates: { canceled: false, canceled_at: null, ended_at: null }, + }), + ], + }), + }); + logChangeResponse("restore / multiple via array", response); + + expectBillingChangeResponse(response, { + updated: ["pro", "seats_addon"], + }); + for (const planId of ["pro", "seats_addon"]) { + const change = findPlanChange(response, { + action: "updated", + planId, + }); + expectPlanChange(change, { + action: "updated", + planId, + previousAttributes: { + canceled_at: NOW - 500, + expires_at: PERIOD_END, + }, + }); + } + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/sync.test.ts b/server/tests/unit/billing/billing-change-response/sync.test.ts new file mode 100644 index 000000000..bc692f7f5 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/sync.test.ts @@ -0,0 +1,82 @@ +import { describe, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — sync (from Stripe)", () => { + test("sync with expire_previous=true", () => { + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + }); + const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + update: makeUpdate({ + customerProduct: oldPro, + updates: { + status: CusProductStatus.Expired, + ended_at: NOW, + canceled: true, + canceled_at: NOW, + }, + }), + }), + }); + logChangeResponse("sync / expire_previous=true", response); + + expectBillingChangeResponse(response, { + expired: ["pro"], + activated: ["pro_v2"], + }); + expectPlanChange( + findPlanChange(response, { action: "expired", planId: "pro" }), + { + action: "expired", + planId: "pro", + previousAttributes: { status: CusProductStatus.Active }, + }, + ); + }); + + test("sync with expire_previous=false (both products active)", () => { + const baseAddon = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + }); + const newAddon = makeFullCusProduct({ + planId: "seats_addon", + startedAt: NOW, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [baseAddon], + }), + autumnBillingPlan: makeAutumnBillingPlan({ inserts: [newAddon] }), + }); + logChangeResponse("sync / expire_previous=false", response); + + expectBillingChangeResponse(response, { activated: ["seats_addon"] }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts new file mode 100644 index 000000000..7238203e5 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse"; +import { + expectBillingChangeResponse, + expectPlanChange, + findPlanChange, +} from "./helpers/expectBillingChange"; +import { logChangeResponse } from "./helpers/logChangeResponse"; +import { + makeAutumnBillingPlan, + makeCustomerEntitlement, + makePatch, + makeUpdate, +} from "./helpers/makeAutumnBillingPlan"; +import { makeFullCusProduct } from "./helpers/makeFullCusProduct"; +import { makeFullCustomer } from "./helpers/makeFullCustomer"; + +const NOW = 1_710_000_000_000; +const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000; +const ctx = {} as AutumnContext; + +describe("buildBillingChangeResponse — updateSubscription", () => { + test("cancel at end of cycle", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + const defaultFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [defaultFree], + update: makeUpdate({ + customerProduct: pro, + updates: { + canceled: true, + canceled_at: NOW, + ended_at: PERIOD_END, + }, + }), + }), + }); + logChangeResponse("update / cancel end of cycle", response); + + expectBillingChangeResponse(response, { + updated: ["pro"], + scheduled: ["free"], + }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + previousAttributes: { canceled_at: null, expires_at: null }, + }); + const scheduled = findPlanChange(response, { + action: "scheduled", + planId: "free", + }); + expect(scheduled?.subscription?.status).toBe("scheduled"); + }); + + test("cancel immediately", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + const defaultFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Active, + startedAt: NOW, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [defaultFree], + update: makeUpdate({ + customerProduct: pro, + updates: { + canceled: true, + canceled_at: NOW, + ended_at: NOW, + status: CusProductStatus.Expired, + }, + }), + }), + }); + logChangeResponse("update / cancel immediately", response); + + expectBillingChangeResponse(response, { + expired: ["pro"], + activated: ["free"], + }); + expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), { + action: "expired", + planId: "pro", + previousAttributes: { + status: CusProductStatus.Active, + canceled_at: null, + expires_at: null, + }, + }); + }); + + test("uncancel", () => { + const pro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + canceledAt: NOW - 500, + endedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + update: makeUpdate({ + customerProduct: pro, + updates: { + canceled: false, + canceled_at: null, + ended_at: null, + }, + }), + }), + }); + logChangeResponse("update / uncancel", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + previousAttributes: { + canceled_at: NOW - 500, + expires_at: PERIOD_END, + }, + }); + const updated = findPlanChange(response, { + action: "updated", + planId: "pro", + }); + expect(updated?.subscription?.canceled_at).toBeNull(); + expect(updated?.subscription?.expires_at).toBeNull(); + }); + + test("update plan via custom plan (replace product)", () => { + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 1000, + }); + const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + update: makeUpdate({ + customerProduct: oldPro, + updates: { status: CusProductStatus.Expired }, + }), + }), + }); + logChangeResponse("update / custom plan replacement", response); + + expectBillingChangeResponse(response, { + expired: ["pro"], + activated: ["pro_v2"], + }); + expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), { + action: "expired", + planId: "pro", + previousAttributes: { status: CusProductStatus.Active }, + }); + }); + + test("delete a scheduled product emits nothing (deletes ignored for now)", () => { + const scheduledFree = makeFullCusProduct({ + planId: "free", + status: CusProductStatus.Scheduled, + startedAt: PERIOD_END, + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [scheduledFree], + }), + autumnBillingPlan: makeAutumnBillingPlan({ deleteOne: scheduledFree }), + }); + logChangeResponse("update / delete scheduled product (ignored)", response); + + expectBillingChangeResponse(response, {}); + expect(response.plan_changes).toEqual([]); + }); + + test("patch items (inline mode) — add and remove features", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + patches: [ + makePatch({ + customerProduct: pro, + insertEntitlements: [ + makeCustomerEntitlement({ featureId: "api_calls" }), + ], + deleteEntitlements: [ + makeCustomerEntitlement({ featureId: "legacy_feature" }), + ], + }), + ], + }), + }); + logChangeResponse("update / patch items", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), { + action: "updated", + planId: "pro", + itemChanges: [ + { action: "created", feature_id: "api_calls" }, + { action: "deleted", feature_id: "legacy_feature" }, + ], + }); + }); + + test("quantity-only update — empty previous_attributes (v1 limitation)", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + update: makeUpdate({ + customerProduct: pro, + updates: { + options: [{ feature_id: "seats", quantity: 10 }], + }, + }), + }), + }); + logChangeResponse("update / quantity only", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + const updated = findPlanChange(response, { + action: "updated", + planId: "pro", + }); + expect(updated?.previous_attributes).toEqual({}); + expect(updated?.item_changes).toEqual([]); + }); + + test("anchor reset — empty updates object", () => { + const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }), + autumnBillingPlan: makeAutumnBillingPlan({ + update: makeUpdate({ customerProduct: pro, updates: {} }), + }), + }); + logChangeResponse("update / empty updates (anchor reset)", response); + + expectBillingChangeResponse(response, { updated: ["pro"] }); + const updated = findPlanChange(response, { + action: "updated", + planId: "pro", + }); + expect(updated?.previous_attributes).toEqual({}); + }); + + // Regression for the collapseSamePlanIdPairs double-match bug: an inserted + // `activated` paired with an expired update of the same plan_id must merge + // to exactly one `updated`; a SECOND expired update for the same plan_id + // must remain a standalone `expired` — not re-pair with the already-merged + // activated entry. + test("collapse same-plan_id pairs — only one activated+expired merges, extras stay as-is", () => { + const newPro = makeFullCusProduct({ + planId: "pro", + status: CusProductStatus.Active, + startedAt: NOW, + id: "cp_pro_new", + }); + const oldPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 30_000, + id: "cp_pro_old", + }); + const olderPro = makeFullCusProduct({ + planId: "pro", + startedAt: NOW - 60_000, + id: "cp_pro_older", + }); + + const response = buildBillingChangeResponse({ + ctx, + originalFullCustomer: makeFullCustomer({ + customerProducts: [oldPro, olderPro], + }), + autumnBillingPlan: makeAutumnBillingPlan({ + inserts: [newPro], + updates: [ + makeUpdate({ + customerProduct: oldPro, + updates: { status: CusProductStatus.Expired }, + }), + makeUpdate({ + customerProduct: olderPro, + updates: { status: CusProductStatus.Expired }, + }), + ], + }), + }); + logChangeResponse("update / triple same-plan_id collapse", response); + + // Expect ONE `updated` (newPro + the first expired merged) and ONE + // `expired` (the second leftover) — not two `updated`. + expectBillingChangeResponse(response, { + updated: ["pro"], + expired: ["pro"], + }); + }); +}); diff --git a/shared/api/billing/common/billingChangeResponse.ts b/shared/api/billing/common/billingChangeResponse.ts new file mode 100644 index 000000000..0446cb85c --- /dev/null +++ b/shared/api/billing/common/billingChangeResponse.ts @@ -0,0 +1,30 @@ +import { z } from "zod/v4"; +import { CustomerPlanChangeSchema } from "./customerPlanChange"; + +export const BillingChangeResponseSchema = z.object({ + object: z.literal("billing.updated"), + customer_id: z.string().meta({ + description: "The ID of the customer whose plans changed.", + }), + entity_id: z + .string() + .nullable() + .optional() + .meta({ + description: + "The ID of the entity, if the changes are scoped to a specific entity.", + }), + plan_changes: z.array(CustomerPlanChangeSchema).meta({ + description: + "The plans that were activated, scheduled, updated, or expired.", + }), + tags: z + .array(z.string()) + .default([]) + .meta({ + description: + "Reason tags describing why this event fired (e.g. 'trial_ended', 'phase_changed'). Always present; empty when no specific reason applies.", + }), +}); + +export type BillingChangeResponse = z.infer; diff --git a/shared/api/billing/common/customerPlanChange.ts b/shared/api/billing/common/customerPlanChange.ts new file mode 100644 index 000000000..5d4736f7a --- /dev/null +++ b/shared/api/billing/common/customerPlanChange.ts @@ -0,0 +1,105 @@ +import { z } from "zod/v4"; + +export const PlanChangeActionEnum = z.enum([ + "activated", + "scheduled", + "updated", + "expired", +]); + +export const SubscriptionStatusEnum = z.enum(["active", "scheduled", "expired"]); + +export const PurchaseStatusEnum = z.enum(["active", "scheduled", "expired"]); + +export const SubscriptionSnapshotSchema = z.object({ + plan_id: z.string().meta({ + description: "The ID of the customer plan.", + }), + status: SubscriptionStatusEnum.meta({ + description: "The current status of the subscription on the customer.", + }), + past_due: z.boolean().meta({ + description: "Whether the subscription has overdue payments.", + }), + started_at: z.number().nullable().meta({ + description: + "When the subscription started, in milliseconds since the Unix epoch.", + }), + canceled_at: z.number().nullable().meta({ + description: + "When the subscription was canceled, in milliseconds since the Unix epoch, or null if not canceled.", + }), + expires_at: z.number().nullable().meta({ + description: + "When the subscription ends, in milliseconds since the Unix epoch, or null if no expiry is set.", + }), + trial_ends_at: z.number().nullable().meta({ + description: + "When the trial ends, in milliseconds since the Unix epoch. Null when not actively trialing.", + }), + current_period_start: z.number().nullable().meta({ + description: "Start of the current billing period, or null if not applicable.", + }), + current_period_end: z.number().nullable().meta({ + description: "End of the current billing period, or null if not applicable.", + }), +}); + +export const PurchaseSnapshotSchema = z.object({ + plan_id: z.string().meta({ + description: "The ID of the customer plan.", + }), + status: PurchaseStatusEnum.meta({ + description: "The current status of the purchase on the customer.", + }), + expires_at: z.number().nullable().meta({ + description: + "When the purchase ends, in milliseconds since the Unix epoch, or null if no expiry is set.", + }), +}); + +export const CustomerPlanItemChangeSchema = z.object({ + action: z.enum(["created", "deleted"]).meta({ + description: "Whether the feature was added to or removed from the plan.", + }), + feature_id: z.string().meta({ + description: "The ID of the feature that was added or removed.", + }), +}); + +export const CustomerPlanChangeSchema = z.object({ + action: PlanChangeActionEnum.meta({ + description: + "The lifecycle action applied to this plan: activated (newly active on the customer), scheduled (queued for a future start), updated (mutated in place), or expired (ended).", + }), + subscription: SubscriptionSnapshotSchema.optional().meta({ + description: + "The subscription as it stands after this change. Present when the plan is billed as a recurring subscription.", + }), + purchase: PurchaseSnapshotSchema.optional().meta({ + description: + "The purchase as it stands after this change. Present when the plan is a one-off purchase.", + }), + previous_attributes: z + .record(z.string(), z.unknown()) + .nullable() + .meta({ + description: + "Sparse map of scalar fields whose values changed, holding their previous values. Null when the plan is newly activated or scheduled.", + }), + item_changes: z + .array(CustomerPlanItemChangeSchema) + .default([]) + .meta({ + description: + "Features that were added to or removed from this plan. Only populated for updated plans.", + }), +}); + +export type PlanChangeAction = z.infer; +export type SubscriptionStatus = z.infer; +export type PurchaseStatus = z.infer; +export type SubscriptionSnapshot = z.infer; +export type PurchaseSnapshot = z.infer; +export type CustomerPlanItemChange = z.infer; +export type CustomerPlanChange = z.infer; diff --git a/shared/api/billing/common/index.ts b/shared/api/billing/common/index.ts index 9cfc436a8..b469221ba 100644 --- a/shared/api/billing/common/index.ts +++ b/shared/api/billing/common/index.ts @@ -1,10 +1,12 @@ export * from "./attachPreviewResponse"; export * from "./billingBehavior"; +export * from "./billingChangeResponse"; export * from "./billingParamsBase/billingParamsBaseV0"; export * from "./billingParamsBase/billingParamsBaseV1"; export * from "./billingPreviewChange"; export * from "./billingPreviewResponse"; export * from "./billingResponse"; +export * from "./customerPlanChange"; export * from "./cancelAction"; export * from "./customizePlan/customizePlanV0"; export * from "./customizePlan/customizePlanV1"; diff --git a/shared/api/webhooks/billing/billingUpdated.ts b/shared/api/webhooks/billing/billingUpdated.ts new file mode 100644 index 000000000..8eb261a3d --- /dev/null +++ b/shared/api/webhooks/billing/billingUpdated.ts @@ -0,0 +1,50 @@ +import { BillingChangeResponseSchema } from "../../billing/common/billingChangeResponse.js"; + +/** + * Webhook payload schema for `billing.updated`. Re-exports + * `BillingChangeResponseSchema` so the public webhook contract stays + * coupled to the same response shape the action layer produces. + */ +export const BillingUpdatedSchema = BillingChangeResponseSchema.meta({ + examples: [ + { + object: "billing.updated", + customer_id: "cus_123", + plan_changes: [ + { + action: "activated", + subscription: { + plan_id: "pro", + status: "active", + past_due: false, + started_at: 1779000000000, + canceled_at: null, + expires_at: null, + trial_ends_at: null, + current_period_start: 1779000000000, + current_period_end: 1781592000000, + }, + previous_attributes: null, + item_changes: [], + }, + { + action: "expired", + subscription: { + plan_id: "free", + status: "expired", + past_due: false, + started_at: 1776000000000, + canceled_at: 1779000000000, + expires_at: 1779000000000, + trial_ends_at: null, + current_period_start: null, + current_period_end: null, + }, + previous_attributes: { status: "active" }, + item_changes: [], + }, + ], + tags: [], + }, + ], +}); diff --git a/shared/api/webhooks/index.ts b/shared/api/webhooks/index.ts index 5df30e731..d4cdad4ca 100644 --- a/shared/api/webhooks/index.ts +++ b/shared/api/webhooks/index.ts @@ -1,6 +1,7 @@ export * from "./balances/balancesLimitReached.js"; export * from "./balances/balancesUsageAlertTriggered.js"; export * from "./billing/billingAutoTopupSucceeded.js"; +export * from "./billing/billingUpdated.js"; export * from "./vercel/index.js"; export * from "./webhookEventType.js"; export * from "./webhookRegistry.js"; diff --git a/shared/api/webhooks/webhookEventType.ts b/shared/api/webhooks/webhookEventType.ts index a81a05b6d..60a32d241 100644 --- a/shared/api/webhooks/webhookEventType.ts +++ b/shared/api/webhooks/webhookEventType.ts @@ -6,6 +6,7 @@ export enum WebhookEventType { BalancesLimitReached = "balances.limit_reached", BillingAutoTopupSucceeded = "billing.auto_topup_succeeded", + BillingUpdated = "billing.updated", VercelResourcesDeleted = "vercel.resources.deleted", VercelResourcesProvisioned = "vercel.resources.provisioned", diff --git a/shared/api/webhooks/webhookRegistry.ts b/shared/api/webhooks/webhookRegistry.ts index d204d6f8e..3904c877d 100644 --- a/shared/api/webhooks/webhookRegistry.ts +++ b/shared/api/webhooks/webhookRegistry.ts @@ -2,6 +2,7 @@ import type { z } from "zod/v4"; import { BalancesLimitReachedSchema } from "./balances/balancesLimitReached.js"; import { BalancesUsageAlertTriggeredSchema } from "./balances/balancesUsageAlertTriggered.js"; import { BillingAutoTopupSucceededSchema } from "./billing/billingAutoTopupSucceeded.js"; +import { BillingUpdatedSchema } from "./billing/billingUpdated.js"; import { VercelResourceDeletedSchema } from "./vercel/vercelResourceDeleted.js"; import { VercelResourceProvisionedSchema } from "./vercel/vercelResourceProvisioned.js"; import { VercelResourceRotateSecretsSchema } from "./vercel/vercelResourceRotateSecrets.js"; @@ -51,6 +52,15 @@ export const webhookRegistry: WebhookDefinition[] = [ description: "Fired when an automatic top-up grants additional prepaid balance.", }, + { + eventType: WebhookEventType.BillingUpdated, + operationId: "billingUpdated", + title: "Plans Updated", + schema: BillingUpdatedSchema, + group: "Billing", + description: + "Fired when a customer's plans change — activated, scheduled, updated, or expired. Each event carries a `plan_changes` array describing what happened and a `tags` array (e.g. `trial_ended`, `phase_changed`) describing why.", + }, // ── Vercel ──────────────────────────────────────────────────────────── { diff --git a/vite/src/hooks/common/useAutumnFlags.tsx b/vite/src/hooks/common/useAutumnFlags.tsx index 9a3bac22a..c9bb8ca53 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -8,7 +8,6 @@ export const useAutumnFlags = () => { const [flags, setFlags] = useLocalStorage("autumn.flags", { pkey: false, - webhooks: true, stripe_key: false, platform: false, vercel: false, @@ -20,7 +19,6 @@ export const useAutumnFlags = () => { const nextFlags = { pkey: notNullish(customer.flags.pkey), - webhooks: true, stripe_key: notNullish(customer.flags.stripe_key), platform: notNullish(customer.flags.platform), vercel: notNullish(customer.flags.vercel), @@ -30,7 +28,6 @@ export const useAutumnFlags = () => { // Only update storage/state when values actually change if ( flags.pkey !== nextFlags.pkey || - flags.webhooks !== nextFlags.webhooks || flags.stripe_key !== nextFlags.stripe_key || flags.platform !== nextFlags.platform || flags.vercel !== nextFlags.vercel || @@ -40,5 +37,6 @@ export const useAutumnFlags = () => { } }, [customer?.flags]); - return { ...flags, webhooks: true }; + // Webhooks have been released publicly — always on. + return { ...flags, webhooks: true as const }; };