99 fullSubjectToUsageWindowLimits ,
1010 getCurrentUsageWindowUsage ,
1111 orgToInStatuses ,
12+ type UsageLimitFilter ,
1213 type UsageLimitWebhookBlock ,
1314 usageLimitFilterMatchesProperties ,
1415 WebhookEventType ,
@@ -17,7 +18,15 @@ import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
1718import type { AutumnContext } from "@/honoUtils/HonoEnv.js" ;
1819import { usageWindowLimitToWebhookBlock } from "@/internal/balances/utils/usageWindows/usageWindowLimitToWebhookBlock.js" ;
1920
20- /** The exhausted cap that blocked this event: the first matching limit with no headroom left. */
21+ type BlockingUsageLimit = {
22+ block : UsageLimitWebhookBlock ;
23+ filter : UsageLimitFilter | undefined ;
24+ } ;
25+
26+ /**
27+ * The cap that blocked this event. Enforcement stops at the cap with the least
28+ * headroom, so the webhook reports that one, filter included, from one source.
29+ */
2130const findBlockingUsageLimit = ( {
2231 ctx,
2332 fullSubject,
@@ -30,31 +39,69 @@ const findBlockingUsageLimit = ({
3039 feature : Feature ;
3140 eventProperties ?: Record < string , unknown > | null ;
3241 now : number ;
33- } ) : UsageLimitWebhookBlock | undefined => {
34- const limits = fullSubjectToUsageWindowLimits ( {
42+ } ) : BlockingUsageLimit | undefined => {
43+ const usageWindows = fullSubject . usage_windows ?? [ ] ;
44+ const measured = fullSubjectToUsageWindowLimits ( {
3545 fullSubject,
3646 featureIds : [ feature . id ] ,
3747 features : ctx . features ,
3848 now,
3949 inStatuses : orgToInStatuses ( { org : ctx . org } ) ,
40- } ) ;
41- const usageWindows = fullSubject . usage_windows ?? [ ] ;
50+ } )
51+ . filter ( ( limit ) =>
52+ usageLimitFilterMatchesProperties ( {
53+ filterProperties : limit . filter_properties ,
54+ eventProperties,
55+ } ) ,
56+ )
57+ . map ( ( limit ) => ( {
58+ limit,
59+ usage : getCurrentUsageWindowUsage ( { usageWindows, limit, now } ) ,
60+ } ) )
61+ . sort (
62+ ( left , right ) =>
63+ left . limit . limit - left . usage - ( right . limit . limit - right . usage ) ,
64+ ) ;
4265
43- for ( const limit of limits ) {
44- const appliesToEvent = usageLimitFilterMatchesProperties ( {
45- filterProperties : limit . filter_properties ,
46- eventProperties,
47- } ) ;
48- if ( ! appliesToEvent ) continue ;
66+ const tightest = measured [ 0 ] ;
67+ if ( ! tightest || tightest . usage < tightest . limit . limit ) return undefined ;
4968
50- const usage = getCurrentUsageWindowUsage ( { usageWindows, limit, now } ) ;
51- if ( usage < limit . limit ) continue ;
69+ const block = usageWindowLimitToWebhookBlock ( {
70+ limit : tightest . limit ,
71+ usage : tightest . usage ,
72+ } ) ;
73+ if ( ! block ) return undefined ;
5274
53- return usageWindowLimitToWebhookBlock ( { limit, usage } ) ?? undefined ;
54- }
55- return undefined ;
75+ return {
76+ block,
77+ filter : tightest . limit . filter_properties
78+ ? { properties : tightest . limit . filter_properties }
79+ : undefined ,
80+ } ;
5681} ;
5782
83+ /** Legacy deductions carry no FullSubject; read the exhausted filtered cap off the evaluated subject. */
84+ const findBlockedFilterOnSubject = ( {
85+ subject,
86+ feature,
87+ eventProperties,
88+ } : {
89+ subject : ApiCustomerV5 | ApiEntityV2 ;
90+ feature : Feature ;
91+ eventProperties ?: Record < string , unknown > | null ;
92+ } ) : UsageLimitFilter | undefined =>
93+ subject . billing_controls ?. usage_limits ?. find (
94+ ( usageLimit ) =>
95+ usageLimit . feature_id === feature . id &&
96+ usageLimit . enabled !== false &&
97+ usageLimit . filter != null &&
98+ usageLimitFilterMatchesProperties ( {
99+ filterProperties : usageLimit . filter . properties ,
100+ eventProperties,
101+ } ) &&
102+ ( usageLimit . usage ?? 0 ) >= usageLimit . limit ,
103+ ) ?. filter ;
104+
58105// Subjects must be built via buildEvaluationSubject, or plan-level / percentage
59106// caps are invisible here and the allowed -> blocked transition never fires.
60107export const checkLimitReached = async ( {
@@ -103,25 +150,7 @@ export const checkLimitReached = async ({
103150 if ( ! oldResult . allowed || newResult . allowed ) return ;
104151
105152 const blockedByUsageLimit = newResult . limitType === "usage_limit" ;
106-
107- // When the blocking cap is a filtered usage limit, attach its filter so
108- // the receiver knows WHICH slice (e.g. which API key) hit its cap.
109- const blockedFilter =
110- blockedByUsageLimit && eventProperties
111- ? newEvalSubject . billing_controls ?. usage_limits ?. find (
112- ( usageLimit ) =>
113- usageLimit . feature_id === feature . id &&
114- usageLimit . enabled !== false &&
115- usageLimit . filter != null &&
116- usageLimitFilterMatchesProperties ( {
117- filterProperties : usageLimit . filter . properties ,
118- eventProperties,
119- } ) &&
120- ( usageLimit . usage ?? 0 ) >= usageLimit . limit ,
121- ) ?. filter
122- : undefined ;
123-
124- const usageLimitBlock =
153+ const blocking =
125154 blockedByUsageLimit && newFullSubject
126155 ? findBlockingUsageLimit ( {
127156 ctx,
@@ -131,6 +160,15 @@ export const checkLimitReached = async ({
131160 now,
132161 } )
133162 : undefined ;
163+ const blockedFilter =
164+ blocking ?. filter ??
165+ ( blockedByUsageLimit && eventProperties
166+ ? findBlockedFilterOnSubject ( {
167+ subject : newEvalSubject ,
168+ feature,
169+ eventProperties,
170+ } )
171+ : undefined ) ;
134172
135173 const customerId = newFullCus . id || newFullCus . internal_id ;
136174 const tags = fullCustomerToTags ( { fullCustomer : newFullCus } ) ;
@@ -144,7 +182,7 @@ export const checkLimitReached = async ({
144182 limit_type : newResult . limitType ?? "included" ,
145183 ...( entityId && { entity_id : entityId } ) ,
146184 ...( blockedFilter && { filter : blockedFilter } ) ,
147- ...( usageLimitBlock && { usage_limit : usageLimitBlock } ) ,
185+ ...( blocking && { usage_limit : blocking . block } ) ,
148186 } ,
149187 tags,
150188 } ) ;
0 commit comments