Skip to content

Commit 3ecb60e

Browse files
committed
fix(balances): limit_reached filter always names the reported cap; shared window measurer
1 parent f5da24c commit 3ecb60e

4 files changed

Lines changed: 51 additions & 57 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const checkLimitReached = async ({
5959
if (!oldResult.allowed || newResult.allowed) return;
6060

6161
const blockedByUsageLimit = newResult.limitType === "usage_limit";
62-
const blocking =
62+
const blockingUsageLimit =
6363
blockedByUsageLimit && newFullSubject
6464
? findBlockingUsageLimit({
6565
ctx,
@@ -68,15 +68,15 @@ export const checkLimitReached = async ({
6868
eventProperties,
6969
})
7070
: undefined;
71-
const blockedFilter =
72-
blocking?.filter ??
73-
(blockedByUsageLimit && eventProperties
71+
const blockedFilter = blockingUsageLimit
72+
? blockingUsageLimit.filter
73+
: blockedByUsageLimit && eventProperties
7474
? findBlockedFilterOnSubject({
7575
subject: newEvalSubject,
7676
feature,
7777
eventProperties,
7878
})
79-
: undefined);
79+
: undefined;
8080

8181
const customerId = newFullCus.id || newFullCus.internal_id;
8282
const tags = fullCustomerToTags({ fullCustomer: newFullCus });
@@ -90,7 +90,7 @@ export const checkLimitReached = async ({
9090
limit_type: newResult.limitType ?? "included",
9191
...(entityId && { entity_id: entityId }),
9292
...(blockedFilter && { filter: blockedFilter }),
93-
...(blocking && { usage_limit: blocking.block }),
93+
...(blockingUsageLimit && { usage_limit: blockingUsageLimit.block }),
9494
},
9595
tags,
9696
});

server/src/internal/balances/trackWebhooks/limitReached/findBlockingUsageLimit.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import {
22
type Feature,
33
type FullSubject,
4-
getCurrentUsageWindowUsage,
4+
subtractSafe,
55
usageLimitFilterMatchesProperties,
66
} from "@autumn/shared";
77
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
8+
import { measureUsageWindowLimit } from "@/internal/balances/utils/usageWindows/measureUsageWindowLimit.js";
89
import { resolveUsageWindowLimits } from "@/internal/balances/utils/usageWindows/resolveUsageWindowLimits.js";
9-
import { usageWindowLimitToWebhookBlock } from "@/internal/balances/utils/usageWindows/usageWindowLimitToWebhookBlock.js";
1010
import type { BlockingUsageLimit } from "./types/blockingUsageLimit.js";
1111

1212
// Enforcement stops at the cap with the least headroom; report that one, filter included.
@@ -34,24 +34,22 @@ export const findBlockingUsageLimit = ({
3434
eventProperties,
3535
}),
3636
)
37-
.map((limit) => ({
38-
limit,
39-
headroom:
40-
limit.limit - getCurrentUsageWindowUsage({ usageWindows, limit, now }),
41-
}))
37+
.flatMap((limit) => {
38+
const measurement = measureUsageWindowLimit({ limit, usageWindows, now });
39+
if (!measurement) return [];
40+
const headroom = subtractSafe({
41+
left: limit.limit,
42+
right: measurement.usage,
43+
});
44+
return [{ limit, block: measurement.block, headroom }];
45+
})
4246
.sort((left, right) => left.headroom - right.headroom);
4347

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

47-
const block = usageWindowLimitToWebhookBlock({
48-
limit: tightest.limit,
49-
usage: tightest.limit.limit - tightest.headroom,
50-
});
51-
if (!block) return undefined;
52-
5351
return {
54-
block,
52+
block: tightest.block,
5553
filter: tightest.limit.filter_properties
5654
? { properties: tightest.limit.filter_properties }
5755
: undefined,

server/tests/integration/balances/track/limit-reached/limit-reached-usage-limit-block.test.ts

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
getTestSvixAppId,
1818
setupWebhookTest,
1919
type WebhookTestSetup,
20-
waitForWebhook,
2120
} from "@tests/integration/utils/svixWebhookTestUtils.js";
2221
import { TestFeature } from "@tests/setup/v2Features.js";
2322
import { items } from "@tests/utils/fixtures/items.js";
@@ -27,31 +26,12 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js";
2726
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
2827
import chalk from "chalk";
2928
import { AutumnInt } from "@/external/autumn/autumnCli.js";
29+
import { waitForLimitReached } from "../../utils/limit-reached-utils/limitReachedWebhookUtils.js";
3030
import { setCustomerUsageLimit } from "../../utils/usage-limit-utils/customerUsageLimitUtils.js";
3131
import { expectUsageLimitWindowContains } from "../../utils/usage-limit-utils/expectUsageLimitWindowContains.js";
3232

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

35-
type LimitReachedPayload = {
36-
type: string;
37-
data: {
38-
customer_id: string;
39-
feature_id: string;
40-
limit_type: string;
41-
entity_id?: string;
42-
filter?: { properties: Record<string, string> };
43-
usage_limit?: {
44-
limit: number;
45-
interval: string;
46-
anchor: string;
47-
usage: number;
48-
remaining: number;
49-
window_start_at: number;
50-
window_end_at: number;
51-
};
52-
};
53-
};
54-
5535
let webhook: WebhookTestSetup;
5636
let playToken: string;
5737

@@ -68,22 +48,6 @@ afterAll(async () => {
6848
await webhook?.cleanup();
6949
});
7050

71-
const waitForLimitReached = ({
72-
customerId,
73-
limitType,
74-
}: {
75-
customerId: string;
76-
limitType: string;
77-
}) =>
78-
waitForWebhook<LimitReachedPayload>({
79-
token: playToken,
80-
predicate: (payload) =>
81-
payload.type === "balances.limit_reached" &&
82-
payload.data?.customer_id === customerId &&
83-
payload.data?.limit_type === limitType,
84-
timeoutMs: 15000,
85-
});
86-
8751
test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the cap that blocked")}`, async () => {
8852
const customerId = "lr-ul-block-1";
8953
const plan = products.base({
@@ -112,6 +76,7 @@ test(`${chalk.yellowBright("limit-reached-ul1: a usage_limit block describes the
11276
});
11377

11478
const result = await waitForLimitReached({
79+
token: playToken,
11580
customerId,
11681
limitType: "usage_limit",
11782
});
@@ -167,6 +132,7 @@ test(`${chalk.yellowBright("limit-reached-ul2: a filtered cap echoes its filter
167132
});
168133

169134
const result = await waitForLimitReached({
135+
token: playToken,
170136
customerId,
171137
limitType: "usage_limit",
172138
});
@@ -198,6 +164,7 @@ test(`${chalk.yellowBright("limit-reached-ul3: an included-allowance block carri
198164
});
199165

200166
const result = await waitForLimitReached({
167+
token: playToken,
201168
customerId,
202169
limitType: "included",
203170
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { BalancesLimitReached } from "@autumn/shared";
2+
import { waitForWebhook } from "@tests/integration/utils/svixWebhookTestUtils.js";
3+
4+
export const LIMIT_REACHED_EVENT_TYPE = "balances.limit_reached";
5+
6+
export type LimitReachedWebhookPayload = {
7+
type: string;
8+
data: BalancesLimitReached;
9+
};
10+
11+
export const waitForLimitReached = ({
12+
token,
13+
customerId,
14+
limitType,
15+
timeoutMs = 15000,
16+
}: {
17+
token: string;
18+
customerId: string;
19+
limitType: string;
20+
timeoutMs?: number;
21+
}) =>
22+
waitForWebhook<LimitReachedWebhookPayload>({
23+
token,
24+
predicate: (payload) =>
25+
payload.type === LIMIT_REACHED_EVENT_TYPE &&
26+
payload.data?.customer_id === customerId &&
27+
payload.data?.limit_type === limitType,
28+
timeoutMs,
29+
});

0 commit comments

Comments
 (0)