Merge pull request #1637 from useautumn/john/billing-webhooks

john: billing webhooks
This commit is contained in:
John Yeo
2026-05-20 11:49:20 +01:00
committed by GitHub
58 changed files with 4053 additions and 88 deletions

View File

@@ -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<void> {
// 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"],

View File

@@ -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<boolean> {
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}`,

View File

@@ -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<typeof customers>;
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"],
});
};

View File

@@ -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<ReturnType<typeof ProductService.listDefault>>;
}) => {
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;

View File

@@ -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<string> };
export const addBillingChangeTag = (
eventContext: BillingChangeTaggable,
tag: string,
): void => {
eventContext.billingChangeTags.add(tag);
};

View File

@@ -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,
});
};

View File

@@ -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;

View File

@@ -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";

View File

@@ -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 });

View File

@@ -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,
});
};

View File

@@ -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<string>;
}
/**
@@ -135,5 +138,6 @@ export const setupStripeSubscriptionDeletedContext = async ({
updatedCustomerProducts: [],
deletedCustomerProducts: [],
insertedCustomerProducts: [],
billingChangeTags: new Set<string>(),
};
};

View File

@@ -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,
});
};

View File

@@ -42,5 +42,6 @@ export const setupStripeSubscriptionUpdatedContext = async ({
updatedCustomerProducts: [],
deletedCustomerProducts: [],
insertedCustomerProducts: [],
billingChangeTags: new Set<string>(),
};
};

View File

@@ -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<string>;
}

View File

@@ -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");
}
};

View File

@@ -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");
};

View File

@@ -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 };
};

View File

@@ -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,
});
};

View File

@@ -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<number>();
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);
};

View File

@@ -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;
};

View File

@@ -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<typeof CustomerProductUpdateSchema>;
const isPastDue = (status: CusProductStatus | undefined): boolean =>
status === CusProductStatus.PastDue;
export const buildPreviousAttributes = ({
originalCusProduct,
updates,
}: {
originalCusProduct: FullCusProduct;
updates: CustomerProductUpdate["updates"];
}): Record<string, unknown> => {
const previous: Record<string, unknown> = {};
// 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;
};

View File

@@ -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";
}
};

View File

@@ -0,0 +1,5 @@
export * from "./buildBillingChangeResponse";
export * from "./buildPlanChanges";
export * from "./buildPlanItemChanges";
export * from "./buildPreviousAttributes";
export * from "./toCustomerPlanSnapshot";

View File

@@ -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,
},
};
};

View File

@@ -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<void> => {
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}`);
}
};

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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",
],
};

View File

@@ -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<string, unknown> | 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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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();
});

View File

@@ -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<BillingUpdatedPayload>({
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");
}
},
);

View File

@@ -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<BillingUpdatedPayload>({
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");
},
);

View File

@@ -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<AttachParamsV1Input>(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<BillingUpdatedPayload>({
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();
},
);

View File

@@ -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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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");
},
);

View File

@@ -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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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<BillingUpdatedPayload>({
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 });
});

View File

@@ -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<BillingUpdatedPayload>({
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);
});

View File

@@ -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<MessageOut> => {
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);

View File

@@ -86,15 +86,23 @@ export const parseEventBody = <T = unknown>(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 <T = unknown>({
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 <T = unknown>({
for (const event of history.data) {
try {
const payload = parseEventBody<T>(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 <T = unknown>({
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<void> => {
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<WebhookTestSetup> => {
// 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}`);

View File

@@ -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");
});
});

View File

@@ -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"],
});
});
});

View File

@@ -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<string>(),
};
};
const updateOf = (
customerProduct: StripeSubscriptionUpdatedContext["updatedCustomerProducts"][number]["customerProduct"],
updates: Partial<InsertCustomerProduct>,
): 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 },
},
);
});
});

View File

@@ -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<string, unknown> | 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);
}
};

View File

@@ -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`);
};

View File

@@ -0,0 +1,73 @@
import type {
AutumnBillingPlan,
CustomerProductUpdateSchema,
FullCusProduct,
FullCustomerEntitlement,
PatchCustomerProductSchema,
} from "@autumn/shared";
import type { z } from "zod/v4";
type CustomerProductUpdate = z.infer<typeof CustomerProductUpdateSchema>;
type PatchCustomerProduct = z.infer<typeof PatchCustomerProductSchema>;
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;

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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" },
],
});
});
});

View File

@@ -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");
});
});

View File

@@ -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,
},
});
}
});
});

View File

@@ -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"] });
});
});

View File

@@ -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"],
});
});
});

View File

@@ -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<typeof BillingChangeResponseSchema>;

View File

@@ -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<typeof PlanChangeActionEnum>;
export type SubscriptionStatus = z.infer<typeof SubscriptionStatusEnum>;
export type PurchaseStatus = z.infer<typeof PurchaseStatusEnum>;
export type SubscriptionSnapshot = z.infer<typeof SubscriptionSnapshotSchema>;
export type PurchaseSnapshot = z.infer<typeof PurchaseSnapshotSchema>;
export type CustomerPlanItemChange = z.infer<typeof CustomerPlanItemChangeSchema>;
export type CustomerPlanChange = z.infer<typeof CustomerPlanChangeSchema>;

View File

@@ -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";

View File

@@ -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: [],
},
],
});

View File

@@ -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";

View File

@@ -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",

View File

@@ -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 ────────────────────────────────────────────────────────────
{

View File

@@ -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 };
};