-
Notifications
You must be signed in to change notification settings - Fork 245
feat(balances): describe the blocking cap on limit_reached #3249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
charlietlamb
merged 8 commits into
charlie/alert-basis-firing
from
charlie/alert-basis-limit-reached
Sep 3, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
51c8642
feat(balances): describe the blocking cap on limit_reached
charlietlamb 2a11a63
fix(balances): limit_reached reports the tightest cap and its filter …
charlietlamb 0d2cfd5
refactor(balances): limit_reached cap selection in its own files
charlietlamb a0ea570
refactor(balances): limit_reached reads the request clock and the sha…
charlietlamb 93a7566
refactor(balances): limit_reached cap lookup reads the clock from ctx
charlietlamb 8f1e5f9
chore: trim comments to why-only
charlietlamb f5da24c
refactor(balances): limit_reached uses the featureIds resolver
charlietlamb 3ecb60e
fix(balances): limit_reached filter always names the reported cap; sh…
charlietlamb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
server/src/internal/balances/trackWebhooks/limitReached/findBlockedFilterOnSubject.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
57 changes: 57 additions & 0 deletions
57
server/src/internal/balances/trackWebhooks/limitReached/findBlockingUsageLimit.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| }; |
6 changes: 6 additions & 0 deletions
6
server/src/internal/balances/trackWebhooks/limitReached/types/blockingUsageLimit.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }; |
173 changes: 173 additions & 0 deletions
173
...er/tests/integration/balances/track/limit-reached/limit-reached-usage-limit-block.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); |
29 changes: 29 additions & 0 deletions
29
server/tests/integration/balances/utils/limit-reached-utils/limitReachedWebhookUtils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.