fix: add tags to webhooks
This commit is contained in:
3
server/src/external/svix/svixHelpers.ts
vendored
3
server/src/external/svix/svixHelpers.ts
vendored
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
15
shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts
Normal file
15
shared/utils/cusUtils/fullCusUtils/fullCustomerToTags.ts
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user