implement auto-topup succeeded webhook and integration test

This commit is contained in:
Owen Greenhalgh
2026-04-30 20:25:15 +01:00
parent 0252ca3bc0
commit 63a340e7a1
8 changed files with 464 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
import type { AppEnv, Organization } from "@autumn/shared";
import * as Sentry from "@sentry/bun";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getSentryTags } from "@/external/sentry/sentryUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { createSvixCli, getSvixAppId, safeSvix } from "./svixUtils.js";
export const createSvixApp = safeSvix({
@@ -42,10 +42,12 @@ export const sendSvixEvent = async ({
ctx,
eventType,
data,
payloadFields,
}: {
ctx: AutumnContext;
eventType: string;
data: unknown;
payloadFields?: { id?: string; occurred_at?: number };
}) => {
if (!process.env.SVIX_API_KEY) return;
@@ -62,6 +64,7 @@ export const sendSvixEvent = async ({
eventType,
payload: {
type: eventType,
...payloadFields,
data,
},
});

View File

@@ -14,6 +14,7 @@ import { clearAutoTopupPendingKey } from "./helpers/enqueueAutoTopupWithBurstSup
import { recordAutoTopupAttempt } from "./helpers/limits/index.js";
import { logAutoTopupContext } from "./logs/logAutoTopupContext.js";
import { setupAutoTopupContext } from "./setup/setupAutoTopupContext.js";
import { sendAutoTopupSucceededWebhook } from "./webhooks/sendAutoTopupSucceededWebhook.js";
/** Workflow handler for auto top-ups. */
export const autoTopup = async ({
@@ -102,15 +103,21 @@ export const autoTopup = async ({
// Manually update cached options here since we're not refreshing cache.
const customerProductUpdate = autumnBillingPlan.updateCustomerProduct;
if (customerProductUpdate?.updates.options) {
const cusProductId = customerProductUpdate.customerProduct.id;
const customerProductId = customerProductUpdate.customerProduct.id;
await updateCachedCustomerProductV2({
ctx,
customerId,
customerProductId: cusProductId,
customerProductId,
updates: customerProductUpdate.updates,
});
}
await sendAutoTopupSucceededWebhook({
ctx,
autoTopupContext,
billingResult,
});
const durationMs = Math.round(performance.now() - start);
logger.info(
`[autoTopup] Completed for feature ${featureId}, customer ${customerId}, duration: ${durationMs}ms`,

View File

@@ -0,0 +1,141 @@
import {
ACTIVE_STATUSES,
type BalancesAutoTopupSucceededInvoice,
type BillingResult,
fullCustomerToCustomerEntitlements,
getApiBalance,
WebhookEventType,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { generateId } from "@/utils/genUtils.js";
import type { AutoTopupContext } from "../autoTopupContext.js";
const getInvoicePayload = ({
billingResult,
}: {
billingResult: BillingResult;
}): BalancesAutoTopupSucceededInvoice | null => {
const stripeInvoice = billingResult.stripe.stripeInvoice;
if (!stripeInvoice) return null;
return {
stripe_id: stripeInvoice.id,
status: stripeInvoice.status,
total: stripeInvoice.total,
currency: stripeInvoice.currency,
hosted_invoice_url: stripeInvoice.hosted_invoice_url,
};
};
// Refetches because executeBillingPlan applied the rebalance via SQL increments;
// the in-memory autoTopupContext.customerEntitlement is now stale.
const getBalanceAfter = async ({
ctx,
autoTopupContext,
}: {
ctx: AutumnContext;
autoTopupContext: AutoTopupContext;
}): Promise<number> => {
const feature = autoTopupContext.customerEntitlement.entitlement.feature;
const customerId =
autoTopupContext.fullCustomer.id ??
autoTopupContext.fullCustomer.internal_id;
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
inStatuses: ACTIVE_STATUSES,
withSubs: true,
});
const customerEntitlements = fullCustomerToCustomerEntitlements({
fullCustomer,
featureId: feature.id,
});
const { data: balance } = getApiBalance({
ctx,
fullCus: fullCustomer,
cusEnts: customerEntitlements,
feature,
});
return balance.remaining;
};
const sendAutoTopupSucceededWebhookUnsafe = async ({
ctx,
autoTopupContext,
billingResult,
}: {
ctx: AutumnContext;
autoTopupContext: AutoTopupContext;
billingResult: BillingResult;
}) => {
const customerProduct = autoTopupContext.customerEntitlement.customer_product;
if (!customerProduct) {
ctx.logger.warn(
"[sendAutoTopupSucceededWebhook] Missing customer product, skipping webhook",
);
return;
}
const invoice = getInvoicePayload({
billingResult,
});
if (!invoice) {
ctx.logger.warn(
"[sendAutoTopupSucceededWebhook] Missing invoice, skipping webhook",
);
return;
}
const customerId =
autoTopupContext.fullCustomer.id ??
autoTopupContext.fullCustomer.internal_id;
const balanceAfter = await getBalanceAfter({
ctx,
autoTopupContext,
});
await sendSvixEvent({
ctx,
eventType: WebhookEventType.BalancesAutoTopupSucceeded,
payloadFields: {
id: generateId("evt_auto_topup"),
occurred_at: Date.now(),
},
data: {
customer_id: customerId,
feature_id: autoTopupContext.autoTopupConfig.feature_id,
customer_product_id: customerProduct.id,
quantity_granted: autoTopupContext.autoTopupConfig.quantity,
threshold: autoTopupContext.autoTopupConfig.threshold,
balance_after: balanceAfter,
invoice_mode: Boolean(autoTopupContext.invoiceMode),
invoice,
},
});
};
export const sendAutoTopupSucceededWebhook = async ({
ctx,
autoTopupContext,
billingResult,
}: {
ctx: AutumnContext;
autoTopupContext: AutoTopupContext;
billingResult: BillingResult;
}) => {
try {
await sendAutoTopupSucceededWebhookUnsafe({
ctx,
autoTopupContext,
billingResult,
});
} catch (error) {
ctx.logger.error(
`[sendAutoTopupSucceededWebhook] Failed to send webhook: ${error}`,
{ error },
);
}
};

View File

@@ -0,0 +1,220 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import {
type ApiCustomerV5,
type BalancesAutoTopupSucceeded,
WebhookEventType,
} from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import {
getTestSvixAppId,
setupWebhookTest,
type WebhookTestSetup,
waitForWebhook,
} from "@tests/integration/utils/svixWebhookTestUtils.js";
import { TestFeature } from "@tests/setup/v2Features.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 { Decimal } from "decimal.js";
import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig.js";
type AutoTopupSucceededPayload = {
type: WebhookEventType.BalancesAutoTopupSucceeded;
id: string;
occurred_at: number;
data: BalancesAutoTopupSucceeded;
};
let webhook: WebhookTestSetup;
let playToken: string;
beforeAll(async () => {
const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
webhook = await setupWebhookTest({
appId,
filterTypes: [WebhookEventType.BalancesAutoTopupSucceeded],
});
playToken = webhook.playToken;
});
afterAll(async () => {
await webhook?.cleanup();
});
test.concurrent(`${chalk.yellowBright("auto-topup webhook: successful auto top-up sends webhook")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProduct = products.oneOffAddOn({
id: "topup-webhook-success",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-webhook-success",
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [oneOffProduct] }),
],
actions: [
s.attach({
productId: oneOffProduct.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
const result = await waitForWebhook<AutoTopupSucceededPayload>({
token: playToken,
predicate: (payload) =>
payload.type === WebhookEventType.BalancesAutoTopupSucceeded &&
payload.data?.customer_id === customerId,
timeoutMs: 30_000,
});
expect(result).not.toBeNull();
const payload = result!.payload;
expect(payload.id).toStartWith("evt_auto_topup_");
expect(payload.occurred_at).toBeGreaterThan(0);
const data = payload.data;
expect(data.customer_id).toBe(customerId);
expect(data.feature_id).toBe(TestFeature.Messages);
expect(typeof data.customer_product_id).toBe("string");
expect(data.customer_product_id.length).toBeGreaterThan(0);
expect(data.quantity_granted).toBe(100);
expect(data.threshold).toBe(20);
expect(data.balance_after).toBe(new Decimal(100).sub(85).add(100).toNumber());
expect(data.invoice_mode).toBe(false);
expect(data.invoice.status).toBe("paid");
expect(data.invoice.stripe_id).toStartWith("in_");
expect(data.invoice.total).toBe(1000);
expect(data.invoice.currency).toBe("usd");
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: new Decimal(100).sub(85).add(100).toNumber(),
});
});
test.concurrent(`${chalk.yellowBright("auto-topup webhook: invoice mode fires with open invoice")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProduct = products.oneOffAddOn({
id: "topup-webhook-invoice-mode",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-webhook-invoice-mode",
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [oneOffProduct] }),
],
actions: [
s.attach({
productId: oneOffProduct.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
invoiceMode: true,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
const result = await waitForWebhook<AutoTopupSucceededPayload>({
token: playToken,
predicate: (payload) =>
payload.type === WebhookEventType.BalancesAutoTopupSucceeded &&
payload.data?.customer_id === customerId,
timeoutMs: 30_000,
});
expect(result).not.toBeNull();
const data = result!.payload.data;
expect(data.invoice_mode).toBe(true);
expect(data.invoice.status).not.toBe("void");
expect(data.invoice.status).not.toBe("paid");
expect(data.balance_after).toBe(new Decimal(100).sub(85).add(100).toNumber());
});
test.concurrent(`${chalk.yellowBright("auto-topup webhook: no webhook when balance remains above threshold")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProduct = products.oneOffAddOn({
id: "topup-webhook-no-fire",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-webhook-no-fire",
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [oneOffProduct] }),
],
actions: [
s.attach({
productId: oneOffProduct.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
const result = await waitForWebhook<AutoTopupSucceededPayload>({
token: playToken,
predicate: (payload) =>
payload.type === WebhookEventType.BalancesAutoTopupSucceeded &&
payload.data?.customer_id === customerId,
timeoutMs: 5_000,
});
expect(result).toBeNull();
});

View File

@@ -0,0 +1,78 @@
import { z } from "zod/v4";
export const BalancesAutoTopupSucceededInvoiceSchema = z.object({
stripe_id: z.string().meta({
description: "The Stripe invoice ID.",
}),
status: z.string().nullish().meta({
description: "The status of the invoice.",
}),
total: z.number().meta({
description: "The total amount of the invoice.",
}),
currency: z.string().meta({
description: "The currency code for the invoice.",
}),
hosted_invoice_url: z.string().nullish().meta({
description: "URL to the hosted invoice page, if available.",
}),
});
export const BALANCES_AUTO_TOPUP_SUCCEEDED_EXAMPLE = {
customer_id: "cus_123",
feature_id: "messages",
customer_product_id: "cp_123",
quantity_granted: 100,
threshold: 20,
balance_after: 115,
invoice_mode: false,
invoice: {
stripe_id: "in_1A2B3C4D5E6F7G8H",
status: "paid",
total: 1000,
currency: "usd",
hosted_invoice_url: "https://invoice.stripe.com/i/acct_123/test_456",
},
};
export const BalancesAutoTopupSucceededSchema = z
.object({
customer_id: z.string().meta({
description: "The ID of the customer whose balance was topped up.",
}),
feature_id: z.string().meta({
description: "The feature ID that was automatically topped up.",
}),
customer_product_id: z.string().meta({
description:
"The Autumn customer product ID whose prepaid quantity was updated.",
}),
quantity_granted: z.number().meta({
description: "The normalized amount of balance granted by the top-up.",
}),
threshold: z.number().meta({
description:
"The configured balance threshold that triggered the top-up.",
}),
balance_after: z.number().meta({
description:
"The customer's remaining balance for the feature after the top-up.",
}),
invoice_mode: z.boolean().meta({
description:
"Whether the auto top-up created a send_invoice invoice instead of auto-charging.",
}),
invoice: BalancesAutoTopupSucceededInvoiceSchema.meta({
description: "The invoice created for the auto top-up.",
}),
})
.meta({
examples: [BALANCES_AUTO_TOPUP_SUCCEEDED_EXAMPLE],
});
export type BalancesAutoTopupSucceeded = z.infer<
typeof BalancesAutoTopupSucceededSchema
>;
export type BalancesAutoTopupSucceededInvoice = z.infer<
typeof BalancesAutoTopupSucceededInvoiceSchema
>;

View File

@@ -1,3 +1,4 @@
export * from "./balances/balancesAutoTopupSucceeded.js";
export * from "./balances/balancesLimitReached.js";
export * from "./balances/balancesUsageAlertTriggered.js";
export * from "./vercel/index.js";

View File

@@ -4,6 +4,7 @@ export enum WebhookEventType {
BalancesUsageAlertTriggered = "balances.usage_alert_triggered",
BalancesLimitReached = "balances.limit_reached",
BalancesAutoTopupSucceeded = "balances.auto_topup_succeeded",
VercelResourcesDeleted = "vercel.resources.deleted",
VercelResourcesProvisioned = "vercel.resources.provisioned",

View File

@@ -1,4 +1,5 @@
import type { z } from "zod/v4";
import { BalancesAutoTopupSucceededSchema } from "./balances/balancesAutoTopupSucceeded.js";
import { BalancesLimitReachedSchema } from "./balances/balancesLimitReached.js";
import { BalancesUsageAlertTriggeredSchema } from "./balances/balancesUsageAlertTriggered.js";
import { VercelResourceDeletedSchema } from "./vercel/vercelResourceDeleted.js";
@@ -39,6 +40,15 @@ export const webhookRegistry: WebhookDefinition[] = [
description:
"Fired when a customer reaches the limit for a feature (included allowance, max purchase, or spend limit).",
},
{
eventType: WebhookEventType.BalancesAutoTopupSucceeded,
operationId: "balancesAutoTopupSucceeded",
title: "Auto Top-Up Succeeded",
schema: BalancesAutoTopupSucceededSchema,
group: "Balances",
description:
"Fired when an automatic top-up grants additional prepaid balance.",
},
// ── Vercel ────────────────────────────────────────────────────────────
{