Skip to content

Commit 45006fb

Browse files
committed
feat(balances): describe the blocking cap on limit_reached
balances.limit_reached carries a usage_limit block (limit, interval, anchor, usage, remaining, window bounds) when a usage limit blocked the event, resolved from the FullSubject the way alerts resolve it. The schema also declares the filter field the payload already sent.
1 parent 474aac6 commit 45006fb

4 files changed

Lines changed: 285 additions & 1 deletion

File tree

server/src/internal/balances/trackWebhooks/checkLimitReached.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,56 @@ import {
44
apiBalanceToAllowed,
55
type Feature,
66
type FullCustomer,
7+
type FullSubject,
78
fullCustomerToTags,
9+
fullSubjectToUsageWindowLimits,
10+
getCurrentUsageWindowUsage,
11+
orgToInStatuses,
12+
type UsageLimitWebhookBlock,
813
usageLimitFilterMatchesProperties,
914
WebhookEventType,
1015
} from "@autumn/shared";
1116
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
1217
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
18+
import { usageWindowLimitToWebhookBlock } from "@/internal/balances/utils/usageWindows/usageWindowLimitToWebhookBlock.js";
19+
20+
/** The exhausted cap that blocked this event: the first matching limit with no headroom left. */
21+
const findBlockingUsageLimit = ({
22+
ctx,
23+
fullSubject,
24+
feature,
25+
eventProperties,
26+
now,
27+
}: {
28+
ctx: AutumnContext;
29+
fullSubject: FullSubject;
30+
feature: Feature;
31+
eventProperties?: Record<string, unknown> | null;
32+
now: number;
33+
}): UsageLimitWebhookBlock | undefined => {
34+
const limits = fullSubjectToUsageWindowLimits({
35+
fullSubject,
36+
featureIds: [feature.id],
37+
features: ctx.features,
38+
now,
39+
inStatuses: orgToInStatuses({ org: ctx.org }),
40+
});
41+
const usageWindows = fullSubject.usage_windows ?? [];
42+
43+
for (const limit of limits) {
44+
const appliesToEvent = usageLimitFilterMatchesProperties({
45+
filterProperties: limit.filter_properties,
46+
eventProperties,
47+
});
48+
if (!appliesToEvent) continue;
49+
50+
const usage = getCurrentUsageWindowUsage({ usageWindows, limit, now });
51+
if (usage < limit.limit) continue;
52+
53+
return usageWindowLimitToWebhookBlock({ limit, usage }) ?? undefined;
54+
}
55+
return undefined;
56+
};
1357

1458
// Subjects must be built via buildEvaluationSubject, or plan-level / percentage
1559
// caps are invisible here and the allowed -> blocked transition never fires.
@@ -18,17 +62,21 @@ export const checkLimitReached = async ({
1862
oldEvalSubject,
1963
newEvalSubject,
2064
newFullCus,
65+
newFullSubject,
2166
feature,
2267
entityId,
2368
eventProperties,
69+
now = Date.now(),
2470
}: {
2571
ctx: AutumnContext;
2672
oldEvalSubject: ApiCustomerV5 | ApiEntityV2;
2773
newEvalSubject: ApiCustomerV5 | ApiEntityV2;
2874
newFullCus: FullCustomer;
75+
newFullSubject?: FullSubject;
2976
feature: Feature;
3077
entityId?: string;
3178
eventProperties?: Record<string, unknown> | null;
79+
now?: number;
3280
}) => {
3381
try {
3482
const oldBalance = oldEvalSubject.balances?.[feature.id];
@@ -54,10 +102,12 @@ export const checkLimitReached = async ({
54102

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

105+
const blockedByUsageLimit = newResult.limitType === "usage_limit";
106+
57107
// When the blocking cap is a filtered usage limit, attach its filter so
58108
// the receiver knows WHICH slice (e.g. which API key) hit its cap.
59109
const blockedFilter =
60-
newResult.limitType === "usage_limit" && eventProperties
110+
blockedByUsageLimit && eventProperties
61111
? newEvalSubject.billing_controls?.usage_limits?.find(
62112
(usageLimit) =>
63113
usageLimit.feature_id === feature.id &&
@@ -71,6 +121,17 @@ export const checkLimitReached = async ({
71121
)?.filter
72122
: undefined;
73123

124+
const usageLimitBlock =
125+
blockedByUsageLimit && newFullSubject
126+
? findBlockingUsageLimit({
127+
ctx,
128+
fullSubject: newFullSubject,
129+
feature,
130+
eventProperties,
131+
now,
132+
})
133+
: undefined;
134+
74135
const customerId = newFullCus.id || newFullCus.internal_id;
75136
const tags = fullCustomerToTags({ fullCustomer: newFullCus });
76137

@@ -83,6 +144,7 @@ export const checkLimitReached = async ({
83144
limit_type: newResult.limitType ?? "included",
84145
...(entityId && { entity_id: entityId }),
85146
...(blockedFilter && { filter: blockedFilter }),
147+
...(usageLimitBlock && { usage_limit: usageLimitBlock }),
86148
},
87149
tags,
88150
});

server/src/internal/balances/trackWebhooks/fireTrackWebhooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,11 @@ export const fireTrackWebhooks = ({
110110
oldEvalSubject,
111111
newEvalSubject,
112112
newFullCus,
113+
newFullSubject,
113114
feature: affectedFeature,
114115
entityId,
115116
eventProperties,
117+
now,
116118
});
117119
}
118120
})().catch((error) => {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* TDD test for the `usage_limit` block on `balances.limit_reached`.
3+
*
4+
* Contract under test:
5+
* New types/fields:
6+
* - limit_reached.filter (schema catch-up; already emitted for filtered caps)
7+
* - limit_reached.usage_limit { limit, interval, anchor, usage, remaining, window_start_at, window_end_at }
8+
* present iff limit_type is usage_limit; absent for included / spend_limit / max_purchase
9+
*
10+
* Pre-impl red: payload has no usage_limit block.
11+
* Post-impl green: checkLimitReached reads the resolved window limit off the FullSubject.
12+
*/
13+
14+
import { afterAll, beforeAll, expect, test } from "bun:test";
15+
import {
16+
ApiVersion,
17+
EntInterval,
18+
getUsageWindowBounds,
19+
ResetInterval,
20+
} from "@autumn/shared";
21+
import {
22+
getTestSvixAppId,
23+
setupWebhookTest,
24+
type WebhookTestSetup,
25+
waitForWebhook,
26+
} from "@tests/integration/utils/svixWebhookTestUtils.js";
27+
import { TestFeature } from "@tests/setup/v2Features.js";
28+
import { items } from "@tests/utils/fixtures/items.js";
29+
import { products } from "@tests/utils/fixtures/products.js";
30+
import { timeout } from "@tests/utils/genUtils.js";
31+
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
32+
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
33+
import chalk from "chalk";
34+
import { AutumnInt } from "@/external/autumn/autumnCli.js";
35+
import { setCustomerUsageLimit } from "../../utils/usage-limit-utils/customerUsageLimitUtils.js";
36+
37+
const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 });
38+
39+
type LimitReachedPayload = {
40+
type: string;
41+
data: {
42+
customer_id: string;
43+
feature_id: string;
44+
limit_type: string;
45+
entity_id?: string;
46+
filter?: { properties: Record<string, string> };
47+
usage_limit?: {
48+
limit: number;
49+
interval: string;
50+
anchor: string;
51+
usage: number;
52+
remaining: number;
53+
window_start_at: number;
54+
window_end_at: number;
55+
};
56+
};
57+
};
58+
59+
let webhook: WebhookTestSetup;
60+
let playToken: string;
61+
62+
beforeAll(async () => {
63+
const appId = getTestSvixAppId({ svixConfig: ctx.org.svix_config });
64+
webhook = await setupWebhookTest({
65+
appId,
66+
filterTypes: ["balances.limit_reached"],
67+
});
68+
playToken = webhook.playToken;
69+
});
70+
71+
afterAll(async () => {
72+
await webhook?.cleanup();
73+
});
74+
75+
const waitForLimitReached = ({
76+
customerId,
77+
limitType,
78+
}: {
79+
customerId: string;
80+
limitType: string;
81+
}) =>
82+
waitForWebhook<LimitReachedPayload>({
83+
token: playToken,
84+
predicate: (payload) =>
85+
payload.type === "balances.limit_reached" &&
86+
payload.data?.customer_id === customerId &&
87+
payload.data?.limit_type === limitType,
88+
timeoutMs: 15000,
89+
});
90+
91+
test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the cap that blocked")}`, async () => {
92+
const customerId = "lr-ul-block-1";
93+
const plan = products.base({
94+
id: "lr-ul-block",
95+
items: [items.monthlyMessages({ includedUsage: 10000 })],
96+
});
97+
await initScenario({
98+
customerId,
99+
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
100+
actions: [s.billing.attach({ productId: plan.id })],
101+
});
102+
await setCustomerUsageLimit({
103+
autumn: autumnV2_3,
104+
customerId,
105+
featureId: TestFeature.Messages,
106+
limit: 5,
107+
interval: ResetInterval.Day,
108+
anchor: "utc",
109+
});
110+
111+
await autumnV2_3.track({
112+
customer_id: customerId,
113+
feature_id: TestFeature.Messages,
114+
value: 5,
115+
});
116+
117+
const result = await waitForLimitReached({
118+
customerId,
119+
limitType: "usage_limit",
120+
});
121+
expect(result).not.toBeNull();
122+
const bounds = getUsageWindowBounds({
123+
interval: EntInterval.Day,
124+
now: Date.now(),
125+
});
126+
expect(result!.payload.data.filter).toBeUndefined();
127+
expect(result!.payload.data.usage_limit).toEqual({
128+
limit: 5,
129+
interval: "day",
130+
anchor: "utc",
131+
usage: 5,
132+
remaining: 0,
133+
window_start_at: bounds.windowStartAt,
134+
window_end_at: bounds.windowEndAt,
135+
});
136+
});
137+
138+
test(`${chalk.yellowBright("limit-reached-ul2: a filtered cap echoes its filter and filtered counter")}`, async () => {
139+
const customerId = "lr-ul-filter-1";
140+
const plan = products.base({
141+
id: "lr-ul-filter",
142+
items: [items.monthlyMessages({ includedUsage: 10000 })],
143+
});
144+
await initScenario({
145+
customerId,
146+
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
147+
actions: [s.billing.attach({ productId: plan.id })],
148+
});
149+
await timeout(2000);
150+
await autumnV2_3.customers.update(customerId, {
151+
billing_controls: {
152+
usage_limits: [
153+
{
154+
feature_id: TestFeature.Messages,
155+
enabled: true,
156+
limit: 5,
157+
interval: ResetInterval.Day,
158+
anchor: "utc",
159+
filter: { properties: { apiKeyId: "key-a" } },
160+
},
161+
],
162+
},
163+
});
164+
await timeout(3000);
165+
166+
await autumnV2_3.track({
167+
customer_id: customerId,
168+
feature_id: TestFeature.Messages,
169+
value: 5,
170+
properties: { apiKeyId: "key-a" },
171+
});
172+
173+
const result = await waitForLimitReached({
174+
customerId,
175+
limitType: "usage_limit",
176+
});
177+
expect(result).not.toBeNull();
178+
expect(result!.payload.data.filter).toEqual({
179+
properties: { apiKeyId: "key-a" },
180+
});
181+
expect(result!.payload.data.usage_limit?.limit).toBe(5);
182+
expect(result!.payload.data.usage_limit?.usage).toBe(5);
183+
expect(result!.payload.data.usage_limit?.remaining).toBe(0);
184+
});
185+
186+
test(`${chalk.yellowBright("limit-reached-ul3: an included-allowance block carries no usage_limit")}`, async () => {
187+
const customerId = "lr-ul-included-1";
188+
const plan = products.base({
189+
id: "lr-ul-included",
190+
items: [items.monthlyMessages({ includedUsage: 100 })],
191+
});
192+
await initScenario({
193+
customerId,
194+
setup: [s.customer({ testClock: false }), s.products({ list: [plan] })],
195+
actions: [s.billing.attach({ productId: plan.id })],
196+
});
197+
198+
await autumnV2_3.track({
199+
customer_id: customerId,
200+
feature_id: TestFeature.Messages,
201+
value: 100,
202+
});
203+
204+
const result = await waitForLimitReached({
205+
customerId,
206+
limitType: "included",
207+
});
208+
expect(result).not.toBeNull();
209+
expect(result!.payload.data.usage_limit).toBeUndefined();
210+
});

shared/api/webhooks/balances/balancesLimitReached.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { z } from "zod/v4";
2+
import { UsageLimitFilterSchema } from "../../../models/cusModels/billingControls/usageLimit.js";
3+
import { UsageLimitWebhookBlockSchema } from "./usageLimitWebhookBlock.js";
24

35
export const LimitType = z.enum([
46
"included",
@@ -30,6 +32,14 @@ export const BalancesLimitReachedSchema = z
3032
description:
3133
"Which limit was hit: included allowance, max purchase cap, spend limit, or a usage-limit billing control.",
3234
}),
35+
filter: UsageLimitFilterSchema.optional().meta({
36+
description:
37+
"The filter of the usage limit that blocked, when a filtered cap was hit.",
38+
}),
39+
usage_limit: UsageLimitWebhookBlockSchema.optional().meta({
40+
description:
41+
"The usage limit that blocked, with its live window. Present only when limit_type is usage_limit.",
42+
}),
3343
})
3444
.meta({
3545
examples: [BALANCES_LIMIT_REACHED_EXAMPLE],

0 commit comments

Comments
 (0)