From 1c44088b7c74f29724151a637f3adf0d419d37d9 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Tue, 19 May 2026 13:54:06 +0100 Subject: [PATCH] fix: add tags to webhooks --- server/src/external/svix/svixHelpers.ts | 3 + .../handlers/handleProductsUpdated.ts | 2 + .../trackWebhooks/checkLimitReached.ts | 3 + .../trackWebhooks/checkUsageAlerts.ts | 4 + .../trackWebhooks/handleThresholdReached.ts | 3 + .../sendProductsUpdated.ts | 4 + .../autumn-webhooks/svix-message-tags.test.ts | 170 ++++++++++++++++++ .../fullCusUtils/fullCustomerToTags.ts | 15 ++ shared/utils/cusUtils/index.ts | 1 + 9 files changed, 205 insertions(+) create mode 100644 server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts create mode 100644 shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts diff --git a/server/src/external/svix/svixHelpers.ts b/server/src/external/svix/svixHelpers.ts index 6e8219d82..9623c9611 100644 --- a/server/src/external/svix/svixHelpers.ts +++ b/server/src/external/svix/svixHelpers.ts @@ -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, ); diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 77ca1cecd..654dcb7bd 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -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 }), }); }; diff --git a/server/src/internal/balances/trackWebhooks/checkLimitReached.ts b/server/src/internal/balances/trackWebhooks/checkLimitReached.ts index d04f0d35c..843a97571 100644 --- a/server/src/internal/balances/trackWebhooks/checkLimitReached.ts +++ b/server/src/internal/balances/trackWebhooks/checkLimitReached.ts @@ -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( diff --git a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts index cfc9be1d3..21af41053 100644 --- a/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts +++ b/server/src/internal/balances/trackWebhooks/checkUsageAlerts.ts @@ -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( diff --git a/server/src/internal/balances/trackWebhooks/handleThresholdReached.ts b/server/src/internal/balances/trackWebhooks/handleThresholdReached.ts index 66bac97c1..2a56270ef 100644 --- a/server/src/internal/balances/trackWebhooks/handleThresholdReached.ts +++ b/server/src/internal/balances/trackWebhooks/handleThresholdReached.ts @@ -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)", diff --git a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts index 12a743974..2a7c2353e 100644 --- a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts +++ b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts @@ -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, }); }; diff --git a/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts b/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts new file mode 100644 index 000000000..9f1040336 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/svix-message-tags.test.ts @@ -0,0 +1,170 @@ +/** + * Integration tests for Svix message tags on customer.products.updated webhooks. + * + * Tag format: "customer_id." and "entity_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.", no entity_id tag. + * - sendProductsUpdated workflow, entity-level attach: + * outgoing svix message has tags including BOTH + * "customer_id." AND "entity_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({ + 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({ + 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); + }, +); diff --git a/shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts b/shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts new file mode 100644 index 000000000..66d61dea4 --- /dev/null +++ b/shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts @@ -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; +}; diff --git a/shared/utils/cusUtils/index.ts b/shared/utils/cusUtils/index.ts index 074bf897c..ba5c1b6e1 100644 --- a/shared/utils/cusUtils/index.ts +++ b/shared/utils/cusUtils/index.ts @@ -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";