diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java index 30dd5a4ba..6397bfc27 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java @@ -42,22 +42,26 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { } /** - * Interceptor ordering: the legacy {@code UnifiedCreditInterceptor} (registered with default - * order = 0 in {@code CreditInterceptorConfig}) must run BEFORE this one so credit rejections - * short-circuit before we hash inputs. Explicit positive order guarantees this regardless of - * {@code WebMvcConfigurer} bean discovery order. + * The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and + * after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess} + * only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the + * full ordering rationale. */ public static final int INTERCEPTOR_ORDER = 1000; /** - * The entitlement guard runs AFTER the charge interceptor so cap-rejected requests still leave - * the charge interceptor's preHandle state in the consistent open-or-bypassed shape (and so the - * guard's 402 reaches the client without the charge interceptor needing to know about it). - * Spring runs interceptors in registration order on the way in, reverse order on the way out; - * the guard's preHandle short-circuit ({@code return false}) prevents the handler from running - * but Spring still invokes the charge interceptor's afterCompletion to clean up temp files. + * The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in + * ascending order on the way in and skips a later interceptor's {@code preHandle} (and its + * {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request the + * guard refuses (over its free allowance / spending cap, or with no subscription to bill) + * short-circuits with its 402 before the charge interceptor ever runs. A blocked request + * therefore never opens a process, materialises inputs, or writes a charge: a refused operation + * must not bill, and running the guard first guarantees that structurally rather than by + * compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default + * order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection still + * wins. */ - public static final int ENTITLEMENT_GUARD_ORDER = 1100; + public static final int ENTITLEMENT_GUARD_ORDER = 900; @Override public void addInterceptors(InterceptorRegistry registry) { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java new file mode 100644 index 000000000..b766fec4a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java @@ -0,0 +1,22 @@ +package stirling.software.saas.payg.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Locks the interceptor ordering that makes "a blocked request is never charged" hold structurally: + * the {@link stirling.software.saas.payg.entitlement.EntitlementGuard} must run BEFORE the {@link + * PaygChargeInterceptor}. Spring skips a later interceptor's {@code preHandle} once an earlier one + * returns {@code false}, so guard-first means a refused (402) request never reaches {@code + * openProcess}. If these constants are ever reordered the wrong way, refused requests would start + * billing again — this test fails first. + */ +class PaygWebMvcConfigTest { + + @Test + void entitlementGuardRunsBeforeChargeInterceptor() { + assertThat(PaygWebMvcConfig.ENTITLEMENT_GUARD_ORDER) + .isLessThan(PaygWebMvcConfig.INTERCEPTOR_ORDER); + } +}