Merge pull request #1637 from useautumn/john/billing-webhooks
john: billing webhooks
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
server/src/external/stripe/webhookHandlers/common/billingChangeTags.ts
vendored
Normal file
16
server/src/external/stripe/webhookHandlers/common/billingChangeTags.ts
vendored
Normal 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);
|
||||
};
|
||||
36
server/src/external/stripe/webhookHandlers/common/emitBillingChangeWebhook.ts
vendored
Normal file
36
server/src/external/stripe/webhookHandlers/common/emitBillingChangeWebhook.ts
vendored
Normal 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,
|
||||
});
|
||||
};
|
||||
18
server/src/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan.ts
vendored
Normal file
18
server/src/external/stripe/webhookHandlers/common/eventContextToAutumnBillingPlan.ts
vendored
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -42,5 +42,6 @@ export const setupStripeSubscriptionUpdatedContext = async ({
|
||||
updatedCustomerProducts: [],
|
||||
deletedCustomerProducts: [],
|
||||
insertedCustomerProducts: [],
|
||||
billingChangeTags: new Set<string>(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./buildBillingChangeResponse";
|
||||
export * from "./buildPlanChanges";
|
||||
export * from "./buildPlanItemChanges";
|
||||
export * from "./buildPreviousAttributes";
|
||||
export * from "./toCustomerPlanSnapshot";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
251
server/tests/unit/billing/billing-change-response/attach.test.ts
Normal file
251
server/tests/unit/billing/billing-change-response/attach.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
30
shared/api/billing/common/billingChangeResponse.ts
Normal file
30
shared/api/billing/common/billingChangeResponse.ts
Normal 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>;
|
||||
105
shared/api/billing/common/customerPlanChange.ts
Normal file
105
shared/api/billing/common/customerPlanChange.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
50
shared/api/webhooks/billing/billingUpdated.ts
Normal file
50
shared/api/webhooks/billing/billingUpdated.ts
Normal 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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user