fix: add tags to webhooks

This commit is contained in:
johnyeo
2026-05-19 13:54:06 +01:00
parent a6389277ff
commit 1c44088b7c
9 changed files with 205 additions and 0 deletions

View File

@@ -44,12 +44,14 @@ export const sendSvixEvent = async ({
data,
payloadFields,
idempotencyKey,
tags,
}: {
ctx: AutumnContext;
eventType: string;
data: unknown;
payloadFields?: { id?: string; occurred_at?: number };
idempotencyKey?: string;
tags?: string[];
}) => {
if (!process.env.SVIX_API_KEY) return;
@@ -71,6 +73,7 @@ export const sendSvixEvent = async ({
...payloadFields,
data,
},
...(tags && tags.length > 0 ? { tags } : {}),
},
idempotencyKey ? { idempotencyKey } : undefined,
);

View File

@@ -15,6 +15,7 @@ import {
type EntityLegacyData,
type FullCusProduct,
type FullProduct,
fullCustomerToTags,
type Organization,
type PlanLegacyData,
} from "@autumn/shared";
@@ -203,5 +204,6 @@ export const handleProductsUpdated = async ({
entity,
updated_product: versionedPlan,
},
tags: fullCustomerToTags({ fullCustomer: fullCus }),
});
};

View File

@@ -4,6 +4,7 @@ import {
apiBalanceToAllowed,
type Feature,
type FullCustomer,
fullCustomerToTags,
WebhookEventType,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
@@ -79,6 +80,7 @@ const checkLimitForSubject = async ({
if (!oldResult.allowed || newResult.allowed) return;
const customerId = newFullCus.id || newFullCus.internal_id;
const tags = fullCustomerToTags({ fullCustomer: newFullCus });
await sendSvixEvent({
ctx,
@@ -89,6 +91,7 @@ const checkLimitForSubject = async ({
limit_type: newResult.limitType ?? "included",
...(entityId && { entity_id: entityId }),
},
tags,
});
ctx.logger.info(

View File

@@ -4,6 +4,7 @@ import {
type Feature,
type FullCustomer,
fullCustomerToCustomerEntitlements,
fullCustomerToTags,
getApiBalance,
WebhookEventType,
} from "@autumn/shared";
@@ -151,6 +152,8 @@ const processAlerts = async ({
minuteBucket,
].join(":");
const tags = fullCustomerToTags({ fullCustomer: newFullCus });
await sendSvixEvent({
ctx,
eventType: WebhookEventType.BalancesUsageAlertTriggered,
@@ -165,6 +168,7 @@ const processAlerts = async ({
threshold_type: alert.threshold_type,
},
},
tags,
});
ctx.logger.info(

View File

@@ -10,6 +10,7 @@ import {
dbToApiFeatureV1,
type Feature,
type FullCustomer,
fullCustomerToTags,
WebhookEventType,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
@@ -99,6 +100,7 @@ const handleAllowanceUsed = async ({
targetVersion: ctx.apiVersion,
}),
},
tags: fullCustomerToTags({ fullCustomer: newFullCus }),
});
}
};
@@ -172,6 +174,7 @@ export const handleThresholdReached = async ({
targetVersion: ctx.apiVersion,
}),
},
tags: fullCustomerToTags({ fullCustomer: newFullCus }),
});
ctx.logger.info(
"Sent Svix event for threshold reached (type: limit_reached)",

View File

@@ -20,6 +20,7 @@ import {
type EntityLegacyData,
enrichFullCustomerWithEntity,
findCustomerProductById,
fullCustomerToTags,
type PlanLegacyData,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
@@ -138,6 +139,8 @@ export const sendProductsUpdated = async ({
`[sendProductsUpdated] Sending webhook for customer ${customerId}, product ${fullProduct.name}, scenario: ${scenario}`,
);
const tags = fullCustomerToTags({ fullCustomer });
await sendSvixEvent({
ctx,
eventType: "customer.products.updated",
@@ -147,5 +150,6 @@ export const sendProductsUpdated = async ({
entity,
updated_product: versionedPlan,
},
tags,
});
};

View File

@@ -0,0 +1,170 @@
/**
* Integration tests for Svix message tags on customer.products.updated webhooks.
*
* Tag format: "customer_id.<id>" and "entity_id.<id>".
*
* Contract under test:
* New types/fields:
* - sendSvixEvent({ ..., tags?: string[] }): tags optional, forwarded to MessageIn.tags.
* New behaviors:
* - sendProductsUpdated workflow, customer-level attach:
* outgoing svix message has tag "customer_id.<id>", no entity_id tag.
* - sendProductsUpdated workflow, entity-level attach:
* outgoing svix message has tags including BOTH
* "customer_id.<id>" AND "entity_id.<id>".
* Side effects:
* - MessageOut.tags contains the expected entries (verified by fetching
* the message by its svix-id header from the delivered webhook).
*
* Pre-impl red: messages exist but tags are null/absent on MessageOut.tags.
* Post-impl green: MessageOut.tags contains the expected entries.
*/
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 {
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";
type CustomerProductsUpdatedPayload = {
type: string;
data: {
scenario: string;
customer: ApiCustomerV3;
updated_product: ApiProduct;
entity?: ApiEntityV0;
};
};
// ═══════════════════════════════════════════════════════════════════════════════
// SVIX SETUP (shared across all tests)
// ═══════════════════════════════════════════════════════════════════════════════
let webhook: WebhookTestSetup;
let playToken: string;
let appId: string;
let svix: Svix;
beforeAll(async () => {
appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
webhook = await setupWebhookTest({
appId,
filterTypes: ["customer.products.updated"],
});
playToken = webhook.playToken;
const apiKey = process.env.SVIX_API_KEY;
if (!apiKey) throw new Error("SVIX_API_KEY required for tag tests");
svix = new Svix(apiKey);
});
afterAll(async () => {
await webhook?.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════════
// CUSTOMER-LEVEL: customer_id tag only
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("svix tags: customer.products.updated (customer-level) has customer_id tag")}`,
async () => {
const customerId = "webhook-tags-customer-only";
const pro = products.pro({
id: "pro-tags-customer",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Confirm the webhook reached Svix (via Play delivery) and grab the svix-id
// header so we can fetch the underlying message to inspect tags.
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId,
timeoutMs: 20000,
});
expect(result).not.toBeNull();
const svixId = result!.event.headers["svix-id"];
expect(svixId).toBeDefined();
// ── Contract assertion 1: MessageOut.tags contains customer_id tag ───────
const message = await svix.message.get(appId, svixId);
const customerTag = `customer_id.${customerId}`;
expect(message.tags).toContain(customerTag);
// ── Contract assertion 2: no entity_id tag for customer-level event ──────
expect(
message.tags?.some((t) => t.startsWith("entity_id.")),
).toBe(false);
},
);
// ═══════════════════════════════════════════════════════════════════════════════
// ENTITY-LEVEL: customer_id AND entity_id tags
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("svix tags: customer.products.updated (entity-level) has customer_id AND entity_id tags")}`,
async () => {
const customerId = "webhook-tags-entity";
const pro = products.pro({
id: "pro-tags-entity",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", skipWebhooks: true }),
s.products({ list: [pro] }),
s.entities({ count: 1, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: pro.id, entityIndex: 0 })],
});
const entityId = entities[0].id;
// Confirm the webhook reached Svix and grab the svix-id header.
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
token: playToken,
predicate: (payload) =>
payload.type === "customer.products.updated" &&
payload.data?.customer?.id === customerId &&
payload.data?.entity?.id === entityId,
timeoutMs: 20000,
});
expect(result).not.toBeNull();
const svixId = result!.event.headers["svix-id"];
expect(svixId).toBeDefined();
// ── Contract assertion: tags include BOTH customer_id and entity_id ──────
const message = await svix.message.get(appId, svixId);
const customerTag = `customer_id.${customerId}`;
const entityTag = `entity_id.${entityId}`;
expect(message.tags).toContain(customerTag);
expect(message.tags).toContain(entityTag);
},
);

View File

@@ -0,0 +1,15 @@
import type { FullCustomer } from "@models/cusModels/fullCusModel.js";
// Svix tag chars must match [a-zA-Z0-9\-_.], so we separate key/value with `.`
// instead of `:` (rejected with HTTP 422 at message creation).
export const fullCustomerToTags = ({
fullCustomer,
}: {
fullCustomer: FullCustomer;
}): string[] => {
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
const tags = [`customer_id.${customerId}`];
const entityId = fullCustomer.entity?.id;
if (entityId) tags.push(`entity_id.${entityId}`);
return tags;
};

View File

@@ -7,4 +7,5 @@ export * from "./fullCusUtils/enrichFullCustomer";
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements";
export * from "./fullCusUtils/fullCustomerToOverageAllowed";
export * from "./fullCusUtils/fullCustomerToSpendLimit";
export * from "./fullCusUtils/fullCustomerToTags";
export * from "./fullCusUtils/getCusStripeSubCount";