Files
cfw-autumn/server/tests/integration/balances/auto-topup/auto-topup-webhook.test.ts
2026-04-30 20:25:15 +01:00

221 lines
6.2 KiB
TypeScript

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