Skip to content
40 changes: 24 additions & 16 deletions server/src/internal/balances/trackWebhooks/checkLimitReached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
apiBalanceToAllowed,
type Feature,
type FullCustomer,
type FullSubject,
fullCustomerToTags,
usageLimitFilterMatchesProperties,
WebhookEventType,
} from "@autumn/shared";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { findBlockedFilterOnSubject } from "./limitReached/findBlockedFilterOnSubject.js";
import { findBlockingUsageLimit } from "./limitReached/findBlockingUsageLimit.js";

// Subjects must be built via buildEvaluationSubject, or plan-level / percentage
// caps are invisible here and the allowed -> blocked transition never fires.
Expand All @@ -18,6 +20,7 @@ export const checkLimitReached = async ({
oldEvalSubject,
newEvalSubject,
newFullCus,
newFullSubject,
feature,
entityId,
eventProperties,
Expand All @@ -26,6 +29,7 @@ export const checkLimitReached = async ({
oldEvalSubject: ApiCustomerV5 | ApiEntityV2;
newEvalSubject: ApiCustomerV5 | ApiEntityV2;
newFullCus: FullCustomer;
newFullSubject?: FullSubject;
feature: Feature;
entityId?: string;
eventProperties?: Record<string, unknown> | null;
Expand Down Expand Up @@ -54,21 +58,24 @@ export const checkLimitReached = async ({

if (!oldResult.allowed || newResult.allowed) return;

// When the blocking cap is a filtered usage limit, attach its filter so
// the receiver knows WHICH slice (e.g. which API key) hit its cap.
const blockedFilter =
newResult.limitType === "usage_limit" && eventProperties
? newEvalSubject.billing_controls?.usage_limits?.find(
(usageLimit) =>
usageLimit.feature_id === feature.id &&
usageLimit.enabled !== false &&
usageLimit.filter != null &&
usageLimitFilterMatchesProperties({
filterProperties: usageLimit.filter.properties,
eventProperties,
}) &&
(usageLimit.usage ?? 0) >= usageLimit.limit,
)?.filter
const blockedByUsageLimit = newResult.limitType === "usage_limit";
const blockingUsageLimit =
blockedByUsageLimit && newFullSubject
Comment thread
charlietlamb marked this conversation as resolved.
? findBlockingUsageLimit({
ctx,
fullSubject: newFullSubject,
feature,
eventProperties,
})
: undefined;
const blockedFilter = blockingUsageLimit
? blockingUsageLimit.filter
: blockedByUsageLimit && eventProperties
? findBlockedFilterOnSubject({
subject: newEvalSubject,
feature,
eventProperties,
})
: undefined;

const customerId = newFullCus.id || newFullCus.internal_id;
Expand All @@ -83,6 +90,7 @@ export const checkLimitReached = async ({
limit_type: newResult.limitType ?? "included",
...(entityId && { entity_id: entityId }),
...(blockedFilter && { filter: blockedFilter }),
...(blockingUsageLimit && { usage_limit: blockingUsageLimit.block }),
},
tags,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const fireTrackWebhooks = ({
oldEvalSubject,
newEvalSubject,
newFullCus,
newFullSubject,
feature: affectedFeature,
entityId,
eventProperties,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
type ApiCustomerV5,
type ApiEntityV2,
type Feature,
type UsageLimitFilter,
usageLimitFilterMatchesProperties,
} from "@autumn/shared";

// Legacy deductions carry no FullSubject; the evaluated subject is all there is.
export const findBlockedFilterOnSubject = ({
subject,
feature,
eventProperties,
}: {
subject: ApiCustomerV5 | ApiEntityV2;
feature: Feature;
eventProperties?: Record<string, unknown> | null;
}): UsageLimitFilter | undefined =>
subject.billing_controls?.usage_limits?.find(
(usageLimit) =>
usageLimit.feature_id === feature.id &&
usageLimit.enabled !== false &&
usageLimit.filter != null &&
usageLimitFilterMatchesProperties({
filterProperties: usageLimit.filter.properties,
eventProperties,
}) &&
(usageLimit.usage ?? 0) >= usageLimit.limit,
)?.filter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
type Feature,
type FullSubject,
subtractSafe,
usageLimitFilterMatchesProperties,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { measureUsageWindowLimit } from "@/internal/balances/utils/usageWindows/measureUsageWindowLimit.js";
import { resolveUsageWindowLimits } from "@/internal/balances/utils/usageWindows/resolveUsageWindowLimits.js";
import type { BlockingUsageLimit } from "./types/blockingUsageLimit.js";

// Enforcement stops at the cap with the least headroom; report that one, filter included.
export const findBlockingUsageLimit = ({
ctx,
fullSubject,
feature,
eventProperties,
}: {
ctx: AutumnContext;
fullSubject: FullSubject;
feature: Feature;
eventProperties?: Record<string, unknown> | null;
}): BlockingUsageLimit | undefined => {
const now = ctx.timestamp;
const usageWindows = fullSubject.usage_windows ?? [];
const measured = resolveUsageWindowLimits({
ctx,
fullSubject,
featureIds: [feature.id],
})
.filter((limit) =>
usageLimitFilterMatchesProperties({
filterProperties: limit.filter_properties,
eventProperties,
}),
)
.flatMap((limit) => {
const measurement = measureUsageWindowLimit({ limit, usageWindows, now });
if (!measurement) return [];
const headroom = subtractSafe({
left: limit.limit,
right: measurement.usage,
});
return [{ limit, block: measurement.block, headroom }];
})
.sort((left, right) => left.headroom - right.headroom);

const tightest = measured[0];
if (!tightest || tightest.headroom > 0) return undefined;

return {
block: tightest.block,
filter: tightest.limit.filter_properties
? { properties: tightest.limit.filter_properties }
: undefined,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { UsageLimitFilter, UsageLimitWebhookBlock } from "@autumn/shared";

export type BlockingUsageLimit = {
block: UsageLimitWebhookBlock;
filter: UsageLimitFilter | undefined;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* TDD test for the `usage_limit` block on `balances.limit_reached`.
*
* Contract under test:
* New types/fields:
* - limit_reached.filter (schema catch-up; already emitted for filtered caps)
* - limit_reached.usage_limit { limit, interval, anchor, usage, remaining, window_start_at, window_end_at }
* present iff limit_type is usage_limit; absent for included / spend_limit / max_purchase
*
* Pre-impl red: payload has no usage_limit block.
* Post-impl green: checkLimitReached reads the resolved window limit off the FullSubject.
*/

import { afterAll, beforeAll, expect, test } from "bun:test";
import { ApiVersion, ms, ResetInterval } from "@autumn/shared";
import {
getTestSvixAppId,
setupWebhookTest,
type WebhookTestSetup,
} 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 { timeout } from "@tests/utils/genUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { waitForLimitReached } from "../../utils/limit-reached-utils/limitReachedWebhookUtils.js";
import { setCustomerUsageLimit } from "../../utils/usage-limit-utils/customerUsageLimitUtils.js";
import { expectUsageLimitWindowContains } from "../../utils/usage-limit-utils/expectUsageLimitWindowContains.js";

const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 });

let webhook: WebhookTestSetup;
let playToken: string;

beforeAll(async () => {
const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
webhook = await setupWebhookTest({
appId,
filterTypes: ["balances.limit_reached"],
});
playToken = webhook.playToken;
});

afterAll(async () => {
await webhook?.cleanup();
});

test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the cap that blocked")}`, async () => {
const customerId = "lr-ul-block-1";
const plan = products.base({
id: "lr-ul-block",
items: [items.monthlyMessages({ includedUsage: 10000 })],
});
await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
actions: [s.billing.attach({ productId: plan.id })],
});
await setCustomerUsageLimit({
autumn: autumnV2_3,
customerId,
featureId: TestFeature.Messages,
limit: 5,
interval: ResetInterval.Day,
anchor: "utc",
});

const trackedAt = Date.now();
await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 5,
});

const result = await waitForLimitReached({
token: playToken,
customerId,
limitType: "usage_limit",
});
expect(result).not.toBeNull();
expect(result!.payload.data.filter).toBeUndefined();
expect(result!.payload.data.usage_limit).toMatchObject({
limit: 5,
interval: "day",
anchor: "utc",
usage: 5,
remaining: 0,
});
expectUsageLimitWindowContains({
usageLimit: result!.payload.data.usage_limit,
at: trackedAt,
intervalMs: ms.days(1),
});
});

test(`${chalk.yellowBright("limit-reached-ul2: a filtered cap echoes its filter and filtered counter")}`, async () => {
const customerId = "lr-ul-filter-1";
const plan = products.base({
id: "lr-ul-filter",
items: [items.monthlyMessages({ includedUsage: 10000 })],
});
await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
actions: [s.billing.attach({ productId: plan.id })],
});
await timeout(2000);
await autumnV2_3.customers.update(customerId, {
billing_controls: {
usage_limits: [
{
feature_id: TestFeature.Messages,
enabled: true,
limit: 5,
interval: ResetInterval.Day,
anchor: "utc",
filter: { properties: { apiKeyId: "key-a" } },
},
],
},
});
await timeout(3000);

await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 5,
properties: { apiKeyId: "key-a" },
});

const result = await waitForLimitReached({
token: playToken,
customerId,
limitType: "usage_limit",
});
expect(result).not.toBeNull();
expect(result!.payload.data.filter).toEqual({
properties: { apiKeyId: "key-a" },
});
expect(result!.payload.data.usage_limit?.limit).toBe(5);
expect(result!.payload.data.usage_limit?.usage).toBe(5);
expect(result!.payload.data.usage_limit?.remaining).toBe(0);
});

test(`${chalk.yellowBright("limit-reached-ul3: an included-allowance block carries no usage_limit")}`, async () => {
const customerId = "lr-ul-included-1";
const plan = products.base({
id: "lr-ul-included",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
actions: [s.billing.attach({ productId: plan.id })],
});

await autumnV2_3.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});

const result = await waitForLimitReached({
token: playToken,
customerId,
limitType: "included",
});
expect(result).not.toBeNull();
expect(result!.payload.data.usage_limit).toBeUndefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { BalancesLimitReached } from "@autumn/shared";
import { waitForWebhook } from "@tests/integration/utils/svixWebhookTestUtils.js";

export const LIMIT_REACHED_EVENT_TYPE = "balances.limit_reached";

export type LimitReachedWebhookPayload = {
type: string;
data: BalancesLimitReached;
};

export const waitForLimitReached = ({
token,
customerId,
limitType,
timeoutMs = 15000,
}: {
token: string;
customerId: string;
limitType: string;
timeoutMs?: number;
}) =>
waitForWebhook<LimitReachedWebhookPayload>({
token,
predicate: (payload) =>
payload.type === LIMIT_REACHED_EVENT_TYPE &&
payload.data?.customer_id === customerId &&
payload.data?.limit_type === limitType,
timeoutMs,
});
Loading
Loading