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 * 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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/balancesUsageAlertTriggered.js";
|
||||
export * from "./vercel/index.js";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user