implement auto-topup succeeded webhook and integration test
This commit is contained in:
5
server/src/external/svix/svixHelpers.ts
vendored
5
server/src/external/svix/svixHelpers.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
import type { AppEnv, Organization } from "@autumn/shared";
|
import type { AppEnv, Organization } from "@autumn/shared";
|
||||||
import * as Sentry from "@sentry/bun";
|
import * as Sentry from "@sentry/bun";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
|
||||||
import { getSentryTags } from "@/external/sentry/sentryUtils.js";
|
import { getSentryTags } from "@/external/sentry/sentryUtils.js";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { createSvixCli, getSvixAppId, safeSvix } from "./svixUtils.js";
|
import { createSvixCli, getSvixAppId, safeSvix } from "./svixUtils.js";
|
||||||
|
|
||||||
export const createSvixApp = safeSvix({
|
export const createSvixApp = safeSvix({
|
||||||
@@ -42,10 +42,12 @@ export const sendSvixEvent = async ({
|
|||||||
ctx,
|
ctx,
|
||||||
eventType,
|
eventType,
|
||||||
data,
|
data,
|
||||||
|
payloadFields,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
payloadFields?: { id?: string; occurred_at?: number };
|
||||||
}) => {
|
}) => {
|
||||||
if (!process.env.SVIX_API_KEY) return;
|
if (!process.env.SVIX_API_KEY) return;
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export const sendSvixEvent = async ({
|
|||||||
eventType,
|
eventType,
|
||||||
payload: {
|
payload: {
|
||||||
type: eventType,
|
type: eventType,
|
||||||
|
...payloadFields,
|
||||||
data,
|
data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { clearAutoTopupPendingKey } from "./helpers/enqueueAutoTopupWithBurstSup
|
|||||||
import { recordAutoTopupAttempt } from "./helpers/limits/index.js";
|
import { recordAutoTopupAttempt } from "./helpers/limits/index.js";
|
||||||
import { logAutoTopupContext } from "./logs/logAutoTopupContext.js";
|
import { logAutoTopupContext } from "./logs/logAutoTopupContext.js";
|
||||||
import { setupAutoTopupContext } from "./setup/setupAutoTopupContext.js";
|
import { setupAutoTopupContext } from "./setup/setupAutoTopupContext.js";
|
||||||
|
import { sendAutoTopupSucceededWebhook } from "./webhooks/sendAutoTopupSucceededWebhook.js";
|
||||||
|
|
||||||
/** Workflow handler for auto top-ups. */
|
/** Workflow handler for auto top-ups. */
|
||||||
export const autoTopup = async ({
|
export const autoTopup = async ({
|
||||||
@@ -102,15 +103,21 @@ export const autoTopup = async ({
|
|||||||
// Manually update cached options here since we're not refreshing cache.
|
// Manually update cached options here since we're not refreshing cache.
|
||||||
const customerProductUpdate = autumnBillingPlan.updateCustomerProduct;
|
const customerProductUpdate = autumnBillingPlan.updateCustomerProduct;
|
||||||
if (customerProductUpdate?.updates.options) {
|
if (customerProductUpdate?.updates.options) {
|
||||||
const cusProductId = customerProductUpdate.customerProduct.id;
|
const customerProductId = customerProductUpdate.customerProduct.id;
|
||||||
await updateCachedCustomerProductV2({
|
await updateCachedCustomerProductV2({
|
||||||
ctx,
|
ctx,
|
||||||
customerId,
|
customerId,
|
||||||
customerProductId: cusProductId,
|
customerProductId,
|
||||||
updates: customerProductUpdate.updates,
|
updates: customerProductUpdate.updates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendAutoTopupSucceededWebhook({
|
||||||
|
ctx,
|
||||||
|
autoTopupContext,
|
||||||
|
billingResult,
|
||||||
|
});
|
||||||
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
const durationMs = Math.round(performance.now() - start);
|
||||||
logger.info(
|
logger.info(
|
||||||
`[autoTopup] Completed for feature ${featureId}, customer ${customerId}, duration: ${durationMs}ms`,
|
`[autoTopup] Completed for feature ${featureId}, customer ${customerId}, duration: ${durationMs}ms`,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
78
shared/api/webhooks/balances/balancesAutoTopupSucceeded.ts
Normal file
78
shared/api/webhooks/balances/balancesAutoTopupSucceeded.ts
Normal 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
|
||||||
|
>;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./balances/balancesAutoTopupSucceeded.js";
|
||||||
export * from "./balances/balancesLimitReached.js";
|
export * from "./balances/balancesLimitReached.js";
|
||||||
export * from "./balances/balancesUsageAlertTriggered.js";
|
export * from "./balances/balancesUsageAlertTriggered.js";
|
||||||
export * from "./vercel/index.js";
|
export * from "./vercel/index.js";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum WebhookEventType {
|
|||||||
|
|
||||||
BalancesUsageAlertTriggered = "balances.usage_alert_triggered",
|
BalancesUsageAlertTriggered = "balances.usage_alert_triggered",
|
||||||
BalancesLimitReached = "balances.limit_reached",
|
BalancesLimitReached = "balances.limit_reached",
|
||||||
|
BalancesAutoTopupSucceeded = "balances.auto_topup_succeeded",
|
||||||
|
|
||||||
VercelResourcesDeleted = "vercel.resources.deleted",
|
VercelResourcesDeleted = "vercel.resources.deleted",
|
||||||
VercelResourcesProvisioned = "vercel.resources.provisioned",
|
VercelResourcesProvisioned = "vercel.resources.provisioned",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
import { BalancesAutoTopupSucceededSchema } from "./balances/balancesAutoTopupSucceeded.js";
|
||||||
import { BalancesLimitReachedSchema } from "./balances/balancesLimitReached.js";
|
import { BalancesLimitReachedSchema } from "./balances/balancesLimitReached.js";
|
||||||
import { BalancesUsageAlertTriggeredSchema } from "./balances/balancesUsageAlertTriggered.js";
|
import { BalancesUsageAlertTriggeredSchema } from "./balances/balancesUsageAlertTriggered.js";
|
||||||
import { VercelResourceDeletedSchema } from "./vercel/vercelResourceDeleted.js";
|
import { VercelResourceDeletedSchema } from "./vercel/vercelResourceDeleted.js";
|
||||||
@@ -39,6 +40,15 @@ export const webhookRegistry: WebhookDefinition[] = [
|
|||||||
description:
|
description:
|
||||||
"Fired when a customer reaches the limit for a feature (included allowance, max purchase, or spend limit).",
|
"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 ────────────────────────────────────────────────────────────
|
// ── Vercel ────────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user