From 20c88feabb0193f15ce17eb499cf1de5b1e1d488 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:11:06 +0100 Subject: [PATCH] refactor(saas): remove the legacy credits engine (FE + Java) (#6687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete legacy-credits teardown ("Group 3"). The per-user/per-team credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed no PAYG code references it. Authorized to also remove the `TeamCredit` pool + its monthly reset. ## Frontend (saas) - Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`, `types/credits.ts`, `apiKeys/UsageSection.tsx`. - `UseSession.tsx`: removed credit members (`creditBalance`, `creditSummary`, `hasSufficientCredits`, `updateCredits`, `refreshCredits`, `fetchCredits`) + the credit types + global credit-update callback. **Kept** `isPro`/`refreshProStatus` and the Supabase auth subscription listener. - `services/apiClient.ts`: removed the dead `x-credits-remaining` handler + low-credit plumbing (token-refresh / PAYG / 401 logic untouched). - Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`, `auth/teamSession.ts`. ## Java (:saas) **Deleted (15):** `UserCredit`(+repo), `TeamCredit`(+repo)+`TeamCreditService`, `CreditService`, `CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`, `CreditInterceptorConfig`, `UnifiedCreditInterceptor`, `CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+ the CreditController test). **Edited — stripped legacy credit side-effects, preserved auth/role/AI/PAYG logic:** - `AiCreate`/`AiProxyController`: dropped the `X-Credits-Remaining`/`X-Credit-Source` response header (its only consumer, the desktop credit system, was already removed). - `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create + seat-update. - `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped `getOrCreateUserCredits` on signup + the credit field/CORS header. - `UserRoleService`: dropped `resetCycleAllocationForRoleChange`; `ROLE_PRO_USER` grant/revoke preserved. - proprietary `UserRepository`: dropped `findUsersWithApiKeyButNoCredits()`. - Tests updated to drop credit mocks/refs. ## Kept / scope - `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4 / EE effort) and **all PAYG** are untouched. - **No DB tables dropped.** `user_credits`/`team_credits` stay until a later **gated** migration — which this PR unblocks (the JPA entities that pinned them are gone). ## Verify `:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit` (saas) + eslint clean; 0 stray artifacts; no residual source refs to the deleted classes. ## Follow-up (not in this PR) `ErrorTrackingService` (+ `UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a dead island — its only callers were the deleted interceptors. Safe to delete, but it cascades beyond the credit scope, so it's a separate tidy-up. Targets `feat/desktop-cloud-saas-reuse`. --- .../database/repository/UserRepository.java | 9 - .../ai/controller/AiCreateController.java | 52 +- .../saas/ai/controller/AiProxyController.java | 102 +- .../saas/config/CreditInterceptorConfig.java | 31 - .../saas/controller/CreditController.java | 315 ---- .../saas/interceptor/CreditErrorAdvice.java | 307 ---- .../saas/interceptor/CreditSuccessAdvice.java | 228 --- .../interceptor/UnifiedCreditInterceptor.java | 493 ------- .../saas/model/CreditConsumptionResult.java | 69 - .../software/saas/model/TeamCredit.java | 131 -- .../software/saas/model/UserCredit.java | 133 -- .../saas/payg/charge/JobChargeService.java | 10 +- .../payg/filter/PaygChargeInterceptor.java | 4 +- .../saas/payg/filter/PaygWebMvcConfig.java | 17 +- .../saas/repository/TeamCreditRepository.java | 68 - .../saas/repository/UserCreditRepository.java | 148 -- .../SupabaseAuthenticationFilter.java | 13 - .../saas/security/SupabaseSecurityConfig.java | 5 +- .../saas/service/CreditResetScheduler.java | 67 - .../software/saas/service/CreditService.java | 1179 --------------- .../saas/service/SaasTeamService.java | 70 - .../saas/service/TeamCreditService.java | 279 ---- .../saas/service/UserRoleService.java | 48 +- .../software/saas/util/CreditHeaderUtils.java | 97 -- .../ai/controller/AiCreateControllerTest.java | 107 +- .../ai/controller/AiProxyControllerTest.java | 333 +---- .../CreditControllerApiKeyTest.java | 89 -- .../interceptor/CreditErrorAdviceTest.java | 725 --------- .../interceptor/CreditSuccessAdviceTest.java | 679 --------- .../UnifiedCreditInterceptorTest.java | 806 ---------- .../payg/charge/JobChargeServiceTest.java | 2 +- .../PricingPolicyAdminControllerTest.java | 5 +- .../SupabaseAuthenticationFilterTest.java | 8 +- .../service/CreditResetSchedulerTest.java | 219 --- .../saas/service/CreditServiceTest.java | 1291 ----------------- .../StripeAfterCommitOrderingTest.java | 2 +- .../saas/service/TeamCreditServiceTest.java | 612 -------- .../saas/service/UserRoleServiceTest.java | 214 +-- .../saas/util/CreditHeaderUtilsTest.java | 348 ----- .../public/locales/en-GB/translation.toml | 9 - .../public/locales/en-US/translation.toml | 9 - frontend/editor/src/saas/auth/UseSession.tsx | 108 +- frontend/editor/src/saas/auth/teamSession.ts | 9 +- .../saas/components/shared/AppConfigModal.tsx | 31 +- .../shared/config/configSections/ApiKeys.tsx | 18 - .../configSections/apiKeys/UsageSection.tsx | 159 -- .../apiKeys/hooks/useCredits.ts | 40 - frontend/editor/src/saas/hooks/useCredits.ts | 46 - .../editor/src/saas/services/apiClient.ts | 55 +- frontend/editor/src/saas/types/credits.ts | 35 - 50 files changed, 134 insertions(+), 9700 deletions(-) delete mode 100644 app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/controller/CreditController.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/model/CreditConsumptionResult.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/model/TeamCredit.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/model/UserCredit.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/repository/TeamCreditRepository.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/service/CreditService.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/service/TeamCreditService.java delete mode 100644 app/saas/src/main/java/stirling/software/saas/util/CreditHeaderUtils.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/controller/CreditControllerApiKeyTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java delete mode 100644 app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/apiKeys/UsageSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts delete mode 100644 frontend/editor/src/saas/hooks/useCredits.ts delete mode 100644 frontend/editor/src/saas/types/credits.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 6cdfb57ea..4e592e427 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -105,15 +105,6 @@ public interface UserRepository extends JpaRepository { Stream findByUsernameIsNullAndCreatedAtBefore( @Param("cutoffDate") LocalDateTime cutoffDate); - /** Users with an API key but no row in {@code user_credits}. */ - @Query( - value = - "SELECT u.* FROM users u " - + "LEFT JOIN user_credits uc ON uc.user_id = u.user_id " - + "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL", - nativeQuery = true) - List findUsersWithApiKeyButNoCredits(); - /** Single-shot UPDATE that reassigns a user to a different team. */ @Modifying @Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId") diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index ce8555efe..371060c03 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -51,10 +51,7 @@ import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ProcessType; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; -import stirling.software.saas.util.CreditHeaderUtils; @RestController @Profile("saas") @@ -69,10 +66,7 @@ public class AiCreateController { private final AiCreateSessionService sessionService; private final AiCreateProxyService proxyService; private final ObjectMapper objectMapper = new ObjectMapper(); - private final CreditService creditService; - private final TeamCreditService teamCreditService; private final UserRepository userRepository; - private final CreditHeaderUtils creditHeaderUtils; private final JobChargeService jobChargeService; @PostMapping("/sessions") @@ -233,8 +227,7 @@ public class AiCreateController { @PathVariable String sessionId, HttpServletRequest request) { sessionService.getSessionForCurrentUser(sessionId); log.info("AI create fillFields sessionId={}", sessionId); - return proxy( - "POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false); + return proxy("POST", "/api/create/sessions/" + sessionId + "/fields", request, false); } @GetMapping( @@ -243,20 +236,11 @@ public class AiCreateController { public ResponseEntity stream( @PathVariable String sessionId, HttpServletRequest request) { sessionService.getSessionForCurrentUser(sessionId); - return proxy( - "GET", - "/api/create/sessions/" + sessionId + "/stream", - request, - true, - true); // Add credits header: frontend endpoint that triggers AI + return proxy("GET", "/api/create/sessions/" + sessionId + "/stream", request, true); } private ResponseEntity proxy( - String method, - String path, - HttpServletRequest request, - boolean acceptEventStream, - boolean includeCreditsHeader) { + String method, String path, HttpServletRequest request, boolean acceptEventStream) { try { HttpResponse response = proxyService.forward(method, path, request, acceptEventStream); @@ -270,11 +254,6 @@ public class AiCreateController { headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE); } - // Add credit headers if requested - if (includeCreditsHeader) { - addCreditHeaders(headers); - } - StreamingResponseBody body = outputStream -> { try (InputStream inputStream = response.body()) { @@ -302,31 +281,6 @@ public class AiCreateController { .ifPresent(value -> headers.set(headerName, value)); } - /** - * Add credit headers to the response headers. - * - * @param headers The headers to add credit information to - */ - private void addCreditHeaders(HttpHeaders headers) { - try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - log.debug("[AI-CREATE] No authentication found, skipping credit header"); - return; - } - - User user = AuthenticationUtils.getCurrentUser(auth, userRepository); - int remainingCredits = - creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService); - if (remainingCredits >= 0) { - headers.set("X-Credits-Remaining", Integer.toString(remainingCredits)); - log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits); - } - } catch (Exception e) { - log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e); - } - } - public record CreateSessionRequest( String prompt, String docType, diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java index 60549208d..82f1678fe 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java @@ -9,8 +9,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,15 +23,9 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; import stirling.software.saas.ai.service.AiProxyService; import stirling.software.saas.payg.cap.RequiresFeature; import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.AuthenticationUtils; -import stirling.software.saas.util.CreditHeaderUtils; @RestController @Profile("saas") @@ -45,103 +37,89 @@ import stirling.software.saas.util.CreditHeaderUtils; public class AiProxyController { private final AiProxyService aiProxyService; - private final CreditService creditService; - private final TeamCreditService teamCreditService; - private final UserRepository userRepository; - private final CreditHeaderUtils creditHeaderUtils; - public AiProxyController( - AiProxyService aiProxyService, - CreditService creditService, - TeamCreditService teamCreditService, - UserRepository userRepository, - CreditHeaderUtils creditHeaderUtils) { + public AiProxyController(AiProxyService aiProxyService) { this.aiProxyService = aiProxyService; - this.creditService = creditService; - this.teamCreditService = teamCreditService; - this.userRepository = userRepository; - this.creditHeaderUtils = creditHeaderUtils; } @PostMapping("/generate_section") public ResponseEntity generateSection(HttpServletRequest request) { - return proxy("POST", "/api/generate_section", request, false, false); + return proxy("POST", "/api/generate_section", request, false); } @PostMapping("/generate_all_sections") public ResponseEntity generateAllSections(HttpServletRequest request) { - return proxy("POST", "/api/generate_all_sections", request, false, false); + return proxy("POST", "/api/generate_all_sections", request, false); } @PostMapping("/intent/check") public ResponseEntity intentCheck(HttpServletRequest request) { - return proxy("POST", "/api/intent/check", request, false, false); + return proxy("POST", "/api/intent/check", request, false); } @PostMapping("/chat/route") public ResponseEntity chatRoute(HttpServletRequest request) { - return proxy("POST", "/api/chat/route", request, false, true); + return proxy("POST", "/api/chat/route", request, false); } @PostMapping("/chat/create-smart-folder") public ResponseEntity createSmartFolder(HttpServletRequest request) { - return proxy("POST", "/api/chat/create-smart-folder", request, false, true); + return proxy("POST", "/api/chat/create-smart-folder", request, false); } @PostMapping("/chat/info") public ResponseEntity chatInfo(HttpServletRequest request) { - return proxy("POST", "/api/chat/info", request, false, true); + return proxy("POST", "/api/chat/info", request, false); } @PostMapping("/pdf/answer") public ResponseEntity pdfAnswer(HttpServletRequest request) { - return proxy("POST", "/api/pdf/answer", request, false, false); + return proxy("POST", "/api/pdf/answer", request, false); } @PostMapping("/progressive_render") public ResponseEntity progressiveRender(HttpServletRequest request) { - return proxy("POST", "/api/progressive_render", request, false, false); + return proxy("POST", "/api/progressive_render", request, false); } @GetMapping("/versions/{userId}") public ResponseEntity versions( @PathVariable("userId") String userId, HttpServletRequest request) { - return proxy("GET", "/api/versions/" + userId, request, false, false); + return proxy("GET", "/api/versions/" + userId, request, false); } @GetMapping("/style/{userId}") public ResponseEntity style( @PathVariable("userId") String userId, HttpServletRequest request) { - return proxy("GET", "/api/style/" + userId, request, false, false); + return proxy("GET", "/api/style/" + userId, request, false); } @PostMapping("/style/{userId}") public ResponseEntity updateStyle( @PathVariable("userId") String userId, HttpServletRequest request) { - return proxy("POST", "/api/style/" + userId, request, false, false); + return proxy("POST", "/api/style/" + userId, request, false); } @PostMapping("/import_template") public ResponseEntity importTemplate(HttpServletRequest request) { - return proxy("POST", "/api/import_template", request, false, false); + return proxy("POST", "/api/import_template", request, false); } @PostMapping("/edit/sessions") public ResponseEntity createEditSession(HttpServletRequest request) { - return proxy("POST", "/api/edit/sessions", request, false, false); + return proxy("POST", "/api/edit/sessions", request, false); } @PostMapping("/edit/sessions/{sessionId}/messages") public ResponseEntity editSessionMessage( @PathVariable("sessionId") String sessionId, HttpServletRequest request) { - return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true); + return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false); } @PostMapping("/edit/sessions/{sessionId}/attachments") public ResponseEntity editSessionAttachment( @PathVariable("sessionId") String sessionId, HttpServletRequest request) { - return proxy( - "POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false); + return proxy("POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false); } @PostMapping( @@ -149,17 +127,17 @@ public class AiProxyController { produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity runEditSession( @PathVariable("sessionId") String sessionId, HttpServletRequest request) { - return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false); + return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true); } @GetMapping("/pdf-editor/document") public ResponseEntity pdfEditorDocument(HttpServletRequest request) { - return proxy("GET", "/api/pdf-editor/document", request, false, false); + return proxy("GET", "/api/pdf-editor/document", request, false); } @PostMapping("/pdf-editor/upload") public ResponseEntity pdfEditorUpload(HttpServletRequest request) { - return proxy("POST", "/api/pdf-editor/upload", request, false, false); + return proxy("POST", "/api/pdf-editor/upload", request, false); } @GetMapping("/output/**") @@ -167,27 +145,22 @@ public class AiProxyController { String requestUri = request.getRequestURI(); String prefix = request.getContextPath() + "/api/v1/ai/output/"; String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : ""; - return proxy("GET", "/output/" + path, request, false, false); + return proxy("GET", "/output/" + path, request, false); } // Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both // proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe. /** - * Proxy method that optionally adds credit headers. + * Proxy method. * * @param method HTTP method * @param path API path * @param request The incoming request * @param acceptEventStream Whether to accept event stream responses - * @param includeCreditsHeader Whether to add credit balance header */ private ResponseEntity proxy( - String method, - String path, - HttpServletRequest request, - boolean acceptEventStream, - boolean includeCreditsHeader) { + String method, String path, HttpServletRequest request, boolean acceptEventStream) { try { // Forward to AI backend HttpResponse aiResponse = @@ -204,11 +177,6 @@ public class AiProxyController { headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE); } - // Add credit headers if requested (after AI processing completes) - if (includeCreditsHeader) { - addCreditHeaders(headers); - } - StreamingResponseBody body = outputStream -> { try (InputStream inputStream = aiResponse.body()) { @@ -247,30 +215,4 @@ public class AiProxyController { } }); } - - /** - * Add credit headers to the response headers. - * - * @param headers The headers to add credit information to - */ - private void addCreditHeaders(HttpHeaders headers) { - try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - log.debug("[AI-PROXY] No authentication found, skipping credit header"); - return; - } - - User user = AuthenticationUtils.getCurrentUser(auth, userRepository); - int remainingCredits = - creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService); - if (remainingCredits >= 0) { - headers.set("X-Credits-Remaining", Integer.toString(remainingCredits)); - log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits); - } - headers.set("X-Credit-Source", "AI_TOOL_CALL"); - } catch (Exception e) { - log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e); - } - } } diff --git a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java deleted file mode 100644 index 2cce36a5a..000000000 --- a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -package stirling.software.saas.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import lombok.RequiredArgsConstructor; - -import stirling.software.saas.interceptor.UnifiedCreditInterceptor; - -// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits -// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back. -@Configuration -@Profile("saas & legacy-credits") -@RequiredArgsConstructor -public class CreditInterceptorConfig implements WebMvcConfigurer { - - private final UnifiedCreditInterceptor unifiedCreditInterceptor; - private final CreditsProperties creditsProperties; - - @Override - public void addInterceptors(InterceptorRegistry registry) { - if (creditsProperties.isEnabled()) { - registry.addInterceptor(unifiedCreditInterceptor) - .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**"); - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java deleted file mode 100644 index 6f33554fc..000000000 --- a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java +++ /dev/null @@ -1,315 +0,0 @@ -package stirling.software.saas.controller; - -import java.util.Map; - -import org.springframework.context.annotation.Profile; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import io.swagger.v3.oas.annotations.Hidden; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.security.EnhancedJwtAuthenticationToken; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.CreditService.CreditSummary; -import stirling.software.saas.util.LogRedactionUtils; - -// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile. -@RestController -@Profile("saas & legacy-credits") -@RequestMapping("/api/v1/credits") -@Tag(name = "Credit Management", description = "Endpoints for managing user API credits") -@RequiredArgsConstructor -@Slf4j -public class CreditController { - - private final CreditService creditService; - - @GetMapping - @Hidden - @Operation( - summary = "Get user credit information", - description = - "Retrieve current credit balance and usage statistics for the authenticated user") - @ApiResponse( - responseCode = "200", - description = "Credit information retrieved successfully", - content = @Content(schema = @Schema(implementation = CreditSummary.class))) - public ResponseEntity getUserCredits(Authentication authentication) { - return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication)); - } - - @PostMapping("/purchase") - @Hidden - @Operation( - summary = "Purchase additional credits", - description = "Add bought credits to user account (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Credits purchased successfully") - public ResponseEntity> purchaseCredits( - @RequestParam("username") String username, @RequestParam("credits") int credits) { - - if (credits <= 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive")); - } - - try { - creditService.addBoughtCredits(username, credits); - log.info("Admin added {} credits to user: {}", credits, username); - return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits)); - } catch (IllegalArgumentException e) { - log.warn("purchaseCredits rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @PostMapping("/purchase-by-supabase-id") - @Hidden - @Operation( - summary = "Purchase additional credits by Supabase ID", - description = "Add bought credits to user account using Supabase ID (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Credits purchased successfully") - public ResponseEntity> purchaseCreditsBySupabaseId( - @RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) { - - if (credits <= 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive")); - } - - try { - creditService.addBoughtCreditsBySupabaseId(supabaseId, credits); - log.info( - "Admin added {} credits to user with Supabase ID: {}", - credits, - LogRedactionUtils.redactSupabaseId(supabaseId)); - return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits)); - } catch (IllegalArgumentException e) { - log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @GetMapping("/user/{username}") - @Hidden - @Operation( - summary = "Get credit information for specific user", - description = "Retrieve credit information for a specific user (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse( - responseCode = "200", - description = "User credit information retrieved successfully", - content = @Content(schema = @Schema(implementation = CreditSummary.class))) - public ResponseEntity getUserCreditsAdmin( - @PathVariable("username") String username) { - CreditSummary summary = creditService.getCreditSummary(username); - return ResponseEntity.ok(summary); - } - - @GetMapping("/user-by-supabase-id/{supabaseId}") - @Hidden - @Operation( - summary = "Get credit information for specific user by Supabase ID", - description = - "Retrieve credit information for a specific user using Supabase ID (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse( - responseCode = "200", - description = "User credit information retrieved successfully", - content = @Content(schema = @Schema(implementation = CreditSummary.class))) - public ResponseEntity getUserCreditsAdminBySupabaseId( - @PathVariable("supabaseId") String supabaseId) { - CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId); - return ResponseEntity.ok(summary); - } - - @PostMapping("/reset-cycle") - @Hidden - @Operation( - summary = "Reset cycle credits for all users", - description = "Manually trigger cycle credit reset for all users (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Cycle credits reset successfully") - public ResponseEntity resetCycleCredits() { - creditService.resetCycleCreditsForAllUsers(); - log.info("Manual cycle credit reset triggered by admin"); - return ResponseEntity.ok("Cycle credits reset successfully for all users"); - } - - @PostMapping("/set-bought-credits") - @Hidden - @Operation( - summary = "Set user's bought credits to a specific amount", - description = - "Hard set the bought credits balance for a specific user to an exact amount (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Bought credits set successfully") - public ResponseEntity> setBoughtCredits( - @RequestParam("username") String username, @RequestParam("credits") int credits) { - - if (credits < 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative")); - } - - try { - creditService.setBoughtCredits(username, credits); - log.info("Admin set bought credits to {} for user: {}", credits, username); - return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits)); - } catch (IllegalArgumentException e) { - log.warn("setBoughtCredits rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @PostMapping("/set-bought-credits-by-supabase-id") - @Hidden - @Operation( - summary = "Set user's bought credits to a specific amount by Supabase ID", - description = - "Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Bought credits set successfully") - public ResponseEntity> setBoughtCreditsBySupabaseId( - @RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) { - - if (credits < 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative")); - } - - try { - creditService.setBoughtCreditsBySupabaseId(supabaseId, credits); - log.info( - "Admin set bought credits to {} for user with Supabase ID: {}", - credits, - LogRedactionUtils.redactSupabaseId(supabaseId)); - return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits)); - } catch (IllegalArgumentException e) { - log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @PostMapping("/set-cycle-credits") - @Hidden - @Operation( - summary = "Set user's cycle credits remaining to a specific amount", - description = - "Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Cycle credits set successfully") - public ResponseEntity> setCycleCredits( - @RequestParam("username") String username, @RequestParam("credits") int credits) { - - if (credits < 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative")); - } - - try { - creditService.setCycleCredits(username, credits); - log.info("Admin set cycle credits to {} for user: {}", credits, username); - return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits)); - } catch (IllegalArgumentException e) { - log.warn("setCycleCredits rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @PostMapping("/set-cycle-credits-by-supabase-id") - @Hidden - @Operation( - summary = "Set user's cycle credits remaining to a specific amount by Supabase ID", - description = - "Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)") - @PreAuthorize("hasRole('ADMIN')") - @ApiResponse(responseCode = "200", description = "Cycle credits set successfully") - public ResponseEntity> setCycleCreditsBySupabaseId( - @RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) { - - if (credits < 0) { - return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative")); - } - - try { - creditService.setCycleCreditsBySupabaseId(supabaseId, credits); - log.info( - "Admin set cycle credits to {} for user with Supabase ID: {}", - credits, - LogRedactionUtils.redactSupabaseId(supabaseId)); - return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits)); - } catch (IllegalArgumentException e) { - log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage()); - return ResponseEntity.badRequest().body(Map.of("error", "Invalid request")); - } - } - - @GetMapping("/usage") - @Hidden - @Operation( - summary = "Get credit usage summary", - description = "Get overview of credit usage (for authenticated user or admin view)") - public ResponseEntity getCreditUsage(Authentication authentication) { - CreditSummary summary = getCreditSummaryForAuthentication(authentication); - - // For unlimited users, don't show meaningless huge usage numbers - int cycleCreditsUsed = - summary.unlimited - ? 0 - : (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining); - - UsageSummary usage = - new UsageSummary( - cycleCreditsUsed, - summary.totalBoughtCredits - summary.boughtCreditsRemaining, - summary.totalAvailableCredits, - summary.unlimited); - - return ResponseEntity.ok(usage); - } - - /** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */ - private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) { - if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) { - return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId()); - } - if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) { - String apiKey = (String) apiKeyToken.getCredentials(); - // Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the - // linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link - // or no User row (e.g. legacy API-key-only deployments). - if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) { - return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString()); - } - return creditService.getCreditSummaryByApiKey(apiKey); - } - return creditService.getCreditSummaryBySupabaseId(authentication.getName()); - } - - public static class UsageSummary { - public final int cycleCreditsUsed; - public final int boughtCreditsUsed; - public final int creditsRemaining; - public final boolean unlimited; - - public UsageSummary( - int cycleCreditsUsed, - int boughtCreditsUsed, - int creditsRemaining, - boolean unlimited) { - this.cycleCreditsUsed = cycleCreditsUsed; - this.boughtCreditsUsed = boughtCreditsUsed; - this.creditsRemaining = creditsRemaining; - this.unlimited = unlimited; - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java deleted file mode 100644 index 24c73ac78..000000000 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java +++ /dev/null @@ -1,307 +0,0 @@ -package stirling.software.saas.interceptor; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; - -import jakarta.servlet.http.HttpServletRequest; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.annotations.AutoJobPostMapping; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.ErrorTrackingService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.AuthenticationUtils; -import stirling.software.saas.util.CreditHeaderUtils; - -/** - * Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global - * exception flow. - */ -// Legacy credit-billing error advice. PAYG handles its own error semantics via -// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed. -@RestControllerAdvice(annotations = AutoJobPostMapping.class) -@Profile("saas & legacy-credits") -@Slf4j -@Order(1) -public class CreditErrorAdvice { - - private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE"; - private static final String ATTR_APIKEY = "CREDIT_API_KEY"; - private static final String ATTR_CHARGED = "CREDIT_CHARGED"; - private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT"; - - private final CreditService creditService; - private final TeamCreditService teamCreditService; - private final UserRepository userRepository; - private final ErrorTrackingService errorTrackingService; - private final SaasTeamExtensionService saasTeamExtensionService; - private final CreditHeaderUtils creditHeaderUtils; - private final Counter creditsConsumedCounter; - // Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper - // bean in the context. Stateless usage, so a fresh instance is fine. - private final ObjectMapper objectMapper = new ObjectMapper(); - - public CreditErrorAdvice( - CreditService creditService, - TeamCreditService teamCreditService, - UserRepository userRepository, - ErrorTrackingService errorTrackingService, - SaasTeamExtensionService saasTeamExtensionService, - CreditHeaderUtils creditHeaderUtils, - MeterRegistry meterRegistry) { - this.creditService = creditService; - this.teamCreditService = teamCreditService; - this.userRepository = userRepository; - this.errorTrackingService = errorTrackingService; - this.saasTeamExtensionService = saasTeamExtensionService; - this.creditHeaderUtils = creditHeaderUtils; - this.creditsConsumedCounter = - Counter.builder("credits.consumed") - .description("Number of credits actually consumed") - .tag("source", "error") - .register(meterRegistry); - } - - @ExceptionHandler(Throwable.class) - public ResponseEntity handleThrowable(HttpServletRequest request, Throwable ex) { - HttpStatus status = determineHttpStatus(ex); - log.debug( - "[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}", - ex.getClass().getSimpleName(), - status); - - String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred"); - // Build error body - Map body = new HashMap<>(); - body.put("error", ex.getClass().getSimpleName()); - body.put("message", message); - body.put("status", status.value()); - - var builder = ResponseEntity.status(status); - - // Handle credit consumption for errors - if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE)) - && request.getAttribute(ATTR_CHARGED) == null) { - - var apiKey = (String) request.getAttribute(ATTR_APIKEY); - var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT); - var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST"); - int creditAmount = resourceWeight != null ? resourceWeight : 1; - - String identifierForErrorTracking = - apiKey; // Keep using apiKey/username for error tracking - if (apiKey != null - && errorTrackingService.recordErrorAndShouldConsumeCredit( - identifierForErrorTracking, - request.getRequestURI(), - ex, - status.value())) { - - // Get current user - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - User user = null; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (Exception e) { - log.warn( - "[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}", - e.getMessage()); - } - - if (user == null) { - log.error( - "[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption"); - } else { - // Check if user is in a non-personal team (must match UnifiedCreditInterceptor - // logic) - Long targetTeamId = null; - if (user.getTeam() != null - && !saasTeamExtensionService.isPersonal(user.getTeam())) { - targetTeamId = user.getTeam().getId(); - } - - boolean consumed = false; - String creditSource = null; - - if (targetTeamId != null) { - // User is in a non-personal team - consume from team credit pool - consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount); - creditSource = "TEAM_CREDITS"; - log.debug( - "[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}", - creditAmount, - targetTeamId); - } else { - // No team - use waterfall logic for individual credits - boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest); - CreditConsumptionResult result = - creditService.consumeCreditWithWaterfall( - user, creditAmount, isApiRequestFlag); - consumed = result.isSuccess(); - creditSource = result.getSource(); - - if (!consumed) { - log.error( - "[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}", - user.getUsername(), - result.getMessage()); - } - } - - if (consumed) { - request.setAttribute(ATTR_CHARGED, Boolean.TRUE); - creditsConsumedCounter.increment(); - - // Set remaining credits header - int remainingCredits = - creditHeaderUtils.getRemainingCredits( - user, creditService, teamCreditService); - if (remainingCredits >= 0) { - builder.header( - "X-Credits-Remaining", Integer.toString(remainingCredits)); - log.warn( - "[CREDIT-HEADER] Added X-Credits-Remaining header: {}", - remainingCredits); - } - if (creditSource != null) { - builder.header("X-Credit-Source", creditSource); - } - - log.info( - "[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)", - creditAmount, - creditSource, - user.getUsername()); - } - } - } else { - log.debug( - "[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error"); - } - } else if (request.getAttribute(ATTR_CHARGED) != null) { - // Already charged, set header if user is authenticated - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.isAuthenticated()) { - try { - User user = AuthenticationUtils.getCurrentUser(auth, userRepository); - int remainingCredits = - creditHeaderUtils.getRemainingCredits( - user, creditService, teamCreditService); - if (remainingCredits >= 0) { - builder.header("X-Credits-Remaining", Integer.toString(remainingCredits)); - log.warn( - "[CREDIT-HEADER] Added X-Credits-Remaining header: {}", - remainingCredits); - } - } catch (Exception e) { - log.debug( - "[CREDIT-HEADER] Could not add credits header for already charged error: {}", - e.getMessage()); - } - } - log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error"); - } - - if (isSseRequest(request)) { - String payload = toJsonPayload(body); - String sseBody = "event: error\ndata: " + payload + "\n\n"; - return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody); - } - - return builder.body(body); - } - - private String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.length() < 8) { - return "***"; - } - return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4); - } - - private HttpStatus determineHttpStatus(Throwable throwable) { - // Map common exceptions to HTTP status codes - String exceptionClass = throwable.getClass().getSimpleName(); - switch (exceptionClass) { - case "IllegalArgumentException": - case "ValidationException": - case "MethodArgumentNotValidException": - return HttpStatus.BAD_REQUEST; - case "AccessDeniedException": - return HttpStatus.FORBIDDEN; - case "UsernameNotFoundException": - return HttpStatus.UNAUTHORIZED; - case "HttpMessageNotReadableException": - return HttpStatus.BAD_REQUEST; - case "MaxUploadSizeExceededException": - return HttpStatus.PAYLOAD_TOO_LARGE; - case "UnsupportedOperationException": - return HttpStatus.NOT_IMPLEMENTED; - default: - // Check error message for clues - String message = throwable.getMessage(); - if (message != null) { - if (message.toLowerCase().contains("validation") - || message.toLowerCase().contains("invalid parameter")) { - return HttpStatus.BAD_REQUEST; - } - if (message.toLowerCase().contains("not found")) { - return HttpStatus.NOT_FOUND; - } - } - return HttpStatus.INTERNAL_SERVER_ERROR; - } - } - - private boolean isSseRequest(HttpServletRequest request) { - String accept = request.getHeader("Accept"); - if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { - return true; - } - String contentType = request.getContentType(); - return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE); - } - - private String toJsonPayload(Map payload) { - try { - return objectMapper.writeValueAsString(payload); - } catch (JsonProcessingException exc) { - log.warn("Failed to serialize SSE error payload, falling back to string", exc); - String message = payload.getOrDefault("message", "An error occurred").toString(); - return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}"; - } - } - - public static class ErrorResponse { - public final String error; - public final String message; - public final int status; - - public ErrorResponse(String error, String message, int status) { - this.error = error; - this.message = message; - this.status = status; - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java deleted file mode 100644 index e76e06e3c..000000000 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java +++ /dev/null @@ -1,228 +0,0 @@ -package stirling.software.saas.interceptor; - -import org.springframework.context.annotation.Profile; -import org.springframework.core.MethodParameter; -import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.server.ServerHttpRequest; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.http.server.ServletServerHttpResponse; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.AuthenticationUtils; -import stirling.software.saas.util.CreditHeaderUtils; - -// Legacy credit-billing success advice. PAYG writes its own ledger entries via -// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed. -@RestControllerAdvice -@Profile("saas & legacy-credits") -@Slf4j -public class CreditSuccessAdvice implements ResponseBodyAdvice { - - private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE"; - private static final String ATTR_APIKEY = "CREDIT_API_KEY"; - private static final String ATTR_CHARGED = "CREDIT_CHARGED"; - private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT"; - - private final CreditService creditService; - private final TeamCreditService teamCreditService; - private final UserRepository userRepository; - private final SaasTeamExtensionService saasTeamExtensionService; - private final CreditHeaderUtils creditHeaderUtils; - private final Counter creditsConsumedCounter; - - public CreditSuccessAdvice( - CreditService creditService, - TeamCreditService teamCreditService, - UserRepository userRepository, - SaasTeamExtensionService saasTeamExtensionService, - CreditHeaderUtils creditHeaderUtils, - MeterRegistry meterRegistry) { - this.creditService = creditService; - this.teamCreditService = teamCreditService; - this.userRepository = userRepository; - this.saasTeamExtensionService = saasTeamExtensionService; - this.creditHeaderUtils = creditHeaderUtils; - this.creditsConsumedCounter = - Counter.builder("credits.consumed") - .description("Number of credits actually consumed") - .tag("source", "success") - .register(meterRegistry); - } - - @Override - public boolean supports( - MethodParameter returnType, Class> converterType) { - // Only REST bodies; this covers @ResponseBody and ResponseEntity - return true; - } - - @Override - public Object beforeBodyWrite( - Object body, - MethodParameter returnType, - MediaType selectedContentType, - Class> selectedConverterType, - ServerHttpRequest request, - ServerHttpResponse response) { - - if (!(request instanceof ServletServerHttpRequest)) { - return body; - } - - var servletReq = ((ServletServerHttpRequest) request).getServletRequest(); - if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) { - return body; - } - - if (servletReq.getAttribute(ATTR_CHARGED) != null) { - return body; - } - - // If the handler returned an error ResponseEntity (>=400) without throwing, - // don't spend here; the error advice will decide. - int status = 200; - if (response instanceof ServletServerHttpResponse) { - status = ((ServletServerHttpResponse) response).getServletResponse().getStatus(); - } - if (status >= 400) { - log.debug( - "[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption", - status); - return body; - } - - var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY); - var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT); - var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST"); - int creditAmount = resourceWeight != null ? resourceWeight : 1; - - if (apiKey != null) { - // Get current user - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - User user = null; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (Exception e) { - log.warn( - "[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}", - e.getMessage()); - } - - if (user == null) { - log.error( - "[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption"); - return body; - } - - // Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic) - // IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits, - // never team credits - boolean isLimitedApiUser = - auth.getAuthorities().stream() - .anyMatch( - authority -> - "ROLE_LIMITED_API_USER".equals(authority.getAuthority()) - || "ROLE_EXTRA_LIMITED_API_USER" - .equals(authority.getAuthority())); - Long targetTeamId = null; - if (!isLimitedApiUser - && user.getTeam() != null - && !saasTeamExtensionService.isPersonal(user.getTeam())) { - targetTeamId = user.getTeam().getId(); - } - - final boolean consumed; - final String creditSource; - - if (targetTeamId != null) { - // User is in a non-personal team - use waterfall with leader overage - CreditConsumptionResult result = - teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount); - consumed = result.isSuccess(); - creditSource = result.getSource(); - - if (!consumed) { - log.error( - "[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:" - + " {}", - result.getMessage()); - } else { - log.debug( - "[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}" - + " via {}", - creditAmount, - targetTeamId, - creditSource); - } - } else { - // No team - use waterfall logic for individual credits - boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest); - CreditConsumptionResult result = - creditService.consumeCreditWithWaterfall( - user, creditAmount, isApiRequestFlag); - consumed = result.isSuccess(); - creditSource = result.getSource(); - - if (!consumed) { - log.error( - "[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}", - user.getUsername(), - result.getMessage()); - } - } - - if (consumed) { - servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE); - creditsConsumedCounter.increment(); - - // Set remaining credits header - int remainingCredits = - creditHeaderUtils.getRemainingCredits( - user, creditService, teamCreditService); - if (remainingCredits >= 0) { - response.getHeaders() - .set("X-Credits-Remaining", Integer.toString(remainingCredits)); - log.warn( - "[CREDIT-HEADER] Added X-Credits-Remaining header: {}", - remainingCredits); - } - if (creditSource != null) { - response.getHeaders().set("X-Credit-Source", creditSource); - } - - log.info( - "[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}", - creditAmount, - creditSource, - user.getUsername()); - } - } else { - log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found"); - } - - return body; - } - - private String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.length() < 8) { - return "***"; - } - return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java deleted file mode 100644 index f50c1df0f..000000000 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java +++ /dev/null @@ -1,493 +0,0 @@ -package stirling.software.saas.interceptor; - -import org.springframework.context.annotation.Profile; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.AsyncHandlerInterceptor; -import org.springframework.web.servlet.ModelAndView; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.annotations.AutoJobPostMapping; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.repository.TeamMembershipRepository; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.ErrorTrackingService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.SaasUserExtensionService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.AuthenticationUtils; - -// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled -// by default in saas, activate legacy-credits profile to bring it back. -@Component -@Profile("saas & legacy-credits") -@Slf4j -public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor { - - private final CreditService creditService; - private final ErrorTrackingService errorTrackingService; - private final CreditsProperties creditsProperties; - private final UserRepository userRepository; - private final TeamCreditService teamCreditService; - private final TeamMembershipRepository membershipRepository; - private final SaasUserExtensionService saasUserExtensionService; - private final SaasTeamExtensionService saasTeamExtensionService; - - private final Counter creditsCheckedCounter; - private final Counter creditsRejectedCounter; - private final Counter jwtBypassCounter; - private final Timer creditCheckTimer; - - private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE"; - private static final String ATTR_API_KEY = "CREDIT_API_KEY"; - private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT"; - private static final String ATTR_CHARGED = "CREDIT_CHARGED"; - - public UnifiedCreditInterceptor( - CreditService creditService, - ErrorTrackingService errorTrackingService, - CreditsProperties creditsProperties, - UserRepository userRepository, - TeamCreditService teamCreditService, - TeamMembershipRepository membershipRepository, - SaasUserExtensionService saasUserExtensionService, - SaasTeamExtensionService saasTeamExtensionService, - MeterRegistry meterRegistry) { - this.creditService = creditService; - this.errorTrackingService = errorTrackingService; - this.creditsProperties = creditsProperties; - this.userRepository = userRepository; - this.teamCreditService = teamCreditService; - this.membershipRepository = membershipRepository; - this.saasUserExtensionService = saasUserExtensionService; - this.saasTeamExtensionService = saasTeamExtensionService; - - this.creditsCheckedCounter = - Counter.builder("credits.validation.checked") - .description("Number of requests that had credit validation performed") - .register(meterRegistry); - this.creditsRejectedCounter = - Counter.builder("credits.validation.rejected") - .description("Number of requests rejected due to insufficient credits") - .register(meterRegistry); - this.jwtBypassCounter = - Counter.builder("credits.validation.jwt_bypass") - .description("Number of JWT requests that bypassed credit validation") - .register(meterRegistry); - this.creditCheckTimer = - Timer.builder("credits.validation.duration") - .description("Time taken to validate credits") - .register(meterRegistry); - } - - @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) - throws Exception { - - log.debug( - "[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}", - handler.getClass().getSimpleName()); - - // Credits system disabled - allow all requests - if (!creditsProperties.isEnabled()) { - log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request"); - return true; - } - - // Only apply to @AutoJobPostMapping endpoints and extract resource weight - if (!(handler instanceof HandlerMethod hm) - || !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) { - log.debug( - "[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)"); - return true; - } - - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - log.debug( - "[CREDIT-DEBUG] Authentication: {}", - auth != null ? auth.getClass().getSimpleName() : "null"); - - User currentUser = null; - - // API key authentication always needs credit validation - if (auth instanceof ApiKeyAuthenticationToken) { - // API key users - proceed with normal credit validation - currentUser = (User) auth.getPrincipal(); - } else if (auth != null && auth.isAuthenticated()) { - // JWT users - get user from authentication details - // JwtAuthenticationToken.getPrincipal() might not be a User object - // so we need to look up the user by the Supabase ID from auth.getName() - - String supabaseId = AuthenticationUtils.extractSupabaseId(auth); - log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId); - - // Look up the User object that should exist (authentication succeeded) - try { - java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId); - java.util.Optional userOpt = userRepository.findBySupabaseId(supabaseUuid); - if (userOpt.isEmpty()) { - log.error( - "[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}", - supabaseId); - response.setStatus(500); - response.setContentType("application/json"); - response.getWriter() - .write( - "{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}"); - return false; - } - currentUser = userOpt.get(); - - if (shouldApplyCreditsToJwtUser(currentUser)) { - // Anonymous users or other limited JWT users should consume credits - log.debug( - "[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role", - currentUser.getUsername()); - } else { - jwtBypassCounter.increment(); - log.debug( - "[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)", - currentUser.getUsername()); - return true; - } - } catch (IllegalArgumentException e) { - log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId); - response.setStatus(400); - response.setContentType("application/json"); - response.getWriter() - .write( - "{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}"); - return false; - } - } else { - // SECURITY: Block all non-authenticated requests - log.warn( - "[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints"); - response.setStatus(401); // 401 Unauthorized - response.setContentType("application/json"); - response.getWriter() - .write( - "{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}"); - return false; - } - - // Extract resource weight from annotation - AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class); - int resourceWeight = - Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100 - - String apiKey = getApiKeyForUser(auth, currentUser); - String maskedApiKey = maskApiKey(apiKey); - - log.debug( - "[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}", - currentUser.getUsername(), - maskedApiKey, - resourceWeight); - - // Track that we're performing credit validation - creditsCheckedCounter.increment(); - - // Check if user has SUFFICIENT credits for this operation (with timing) - Timer.Sample sample = Timer.start(); - boolean hasSufficientCredits; - int availableCredits = 0; - - // Check if user is a limited API user (anonymous, extra limited) - // Limited API users always use personal credits, never team credits - boolean isLimitedApiUser = - currentUser.getAuthorities().stream() - .anyMatch( - authority -> - "ROLE_LIMITED_API_USER".equals(authority.getAuthority()) - || "ROLE_EXTRA_LIMITED_API_USER" - .equals(authority.getAuthority())); - - if (auth instanceof ApiKeyAuthenticationToken) { - // API key auth - get credit balance - java.util.Optional userCreditsOpt = - creditService.getUserCreditsByApiKey(apiKey); - availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0); - hasSufficientCredits = availableCredits >= resourceWeight; - } else { - // JWT user - check team credits if user is in a non-personal team, otherwise personal - // credits - Long teamId = null; - if (!isLimitedApiUser - && currentUser.getTeam() != null - && !saasTeamExtensionService.isPersonal(currentUser.getTeam())) { - teamId = currentUser.getTeam().getId(); - } - - if (teamId != null) { - // User is in a non-personal team - check team credits + leader overage billing - java.util.Optional teamCredits = - teamCreditService.getTeamCredits(teamId); - availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0); - - // Check if sufficient credits OR team leader has metered billing - boolean hasTeamCredits = availableCredits >= resourceWeight; - boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam()); - - hasSufficientCredits = hasTeamCredits || leaderHasMetered; - - log.debug( - "[CREDIT-DEBUG] Checking team {} credits for user {}: available={}" - + " required={} hasCredits={} leaderMetered={} sufficient={}", - teamId, - currentUser.getUsername(), - availableCredits, - resourceWeight, - hasTeamCredits, - leaderHasMetered, - hasSufficientCredits); - } else { - // Personal team or no team - check personal credits - UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser); - availableCredits = userCredits.getTotalAvailableCredits(); - hasSufficientCredits = availableCredits >= resourceWeight; - log.debug( - "[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}", - currentUser.getUsername(), - availableCredits, - resourceWeight, - hasSufficientCredits); - } - } - sample.stop(creditCheckTimer); - - // Check if user has metered billing enabled (they can use overage credits even with - // insufficient free credits) - boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser); - - if (!hasSufficientCredits && !hasMeteredBilling) { - creditsRejectedCounter.increment(); - - // Enhanced message for team members - // Note: Limited API users always use personal credits, so they get personal message - String message; - if (!isLimitedApiUser - && currentUser.getTeam() != null - && !saasTeamExtensionService.isPersonal(currentUser.getTeam())) { - message = - "Insufficient team credits. Team leader must enable overage billing for" - + " uninterrupted service."; - } else { - message = - "Insufficient API credits. Please purchase more credits or wait for your" - + " monthly cycle credits to reset."; - } - - log.warn( - "[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {}," - + " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}", - request.getMethod(), - request.getRequestURI(), - getClientIpAddress(request), - request.getHeader("User-Agent"), - currentUser.getUsername(), - currentUser.getSupabaseId(), - message); - - response.setStatus(429); // 429 Too Many Requests - response.setContentType("application/json"); - response.getWriter() - .write( - String.format( - "{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}", - message)); - response.getWriter().flush(); - return false; - } - - // Log when metered billing users are using overage credits - if (!hasSufficientCredits && hasMeteredBilling) { - log.info( - "[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)", - currentUser.getUsername(), - availableCredits, - resourceWeight); - } - - // Mark request as eligible for credit consumption - request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE); - request.setAttribute(ATTR_API_KEY, apiKey); - request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight); - - // Store whether this is API key or JWT authentication for advice classes - boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken; - request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth); - - // Store IS_API_REQUEST for waterfall logic (API key requests always consume credits) - request.setAttribute("IS_API_REQUEST", isApiKeyAuth); - - log.debug( - "[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)"); - - return true; - } - - @Override - public void postHandle( - HttpServletRequest request, - HttpServletResponse response, - Object handler, - ModelAndView modelAndView) - throws Exception { - // Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore - log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice"); - } - - @Override - public void afterCompletion( - HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) - throws Exception { - // Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore - if (ex != null) { - log.debug( - "[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}", - ex.getClass().getSimpleName()); - } else { - log.debug( - "[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice"); - } - } - - @Override - public void afterConcurrentHandlingStarted( - HttpServletRequest request, HttpServletResponse response, Object handler) - throws Exception { - // For async requests (Callable, DeferredResult, etc.), prevent duplicate processing - // The actual postHandle/afterCompletion will be called when async processing completes - log.debug( - "[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic"); - } - - private String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.length() < 8) { - return "***"; - } - return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4); - } - - private String getClientIpAddress(HttpServletRequest request) { - String xForwardedFor = request.getHeader("X-Forwarded-For"); - if (xForwardedFor != null && !xForwardedFor.isEmpty()) { - return xForwardedFor.split(",")[0].trim(); - } - - String xRealIp = request.getHeader("X-Real-IP"); - if (xRealIp != null && !xRealIp.isEmpty()) { - return xRealIp; - } - - return request.getRemoteAddr(); - } - - /** - * Determines if credit limits should apply to a JWT user. - * - *

Rules: - * - *

    - *
  • Metered billing users: always consume (free tier first, then report overage to Stripe) - *
  • Anonymous users: consume credits (web/API) - *
  • Regular users: consume credits (web/API) - *
  • Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks - *
  • API users: always consume credits - *
  • Internal API users: unlimited everywhere - *
  • Admin users: unlimited everywhere - *
- */ - private boolean shouldApplyCreditsToJwtUser(User user) { - String roles = user.getRolesAsString(); - - // Internal API users are unlimited everywhere (for backend internal operations) - if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) { - log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername()); - return false; - } - - // Pro users: Let them through to waterfall logic - // (Pro gets unlimited UI but API still consumes credits) - if (roles.contains("ROLE_PRO_USER")) { - log.debug( - "[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic", - user.getUsername()); - return true; // Changed from false - let waterfall handle Pro exemption - } - - // Admin users are unlimited everywhere - if (roles.contains("ROLE_ADMIN")) { - log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername()); - return false; - } - - // All other users (anonymous, regular, limited API users, metered billing) consume credits - log.debug( - "[CREDIT-DEBUG] User {} with roles {} - subject to credit limits", - user.getUsername(), - roles); - return true; - } - - /** - * Gets the identifier for credit consumption. For API key users, use their actual API key. For - * JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID). - */ - private String getApiKeyForUser(Authentication auth, User user) { - if (auth instanceof ApiKeyAuthenticationToken) { - return user.getApiKey(); - } else { - // For JWT users, return Supabase ID as the credit consumption identifier - return AuthenticationUtils.extractSupabaseId(auth); - } - } - - /** - * Check if team leader has metered billing enabled. This allows teams to use overage billing - * when team credits are exhausted. - * - * @param team the team to check - * @return true if team leader has metered billing enabled - */ - private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) { - if (team == null || team.getId() == null) { - return false; - } - - try { - java.util.List leaders = - membershipRepository.findByTeamIdAndRole( - team.getId(), - stirling.software.common.model.enumeration.TeamRole.LEADER); - - if (leaders.isEmpty()) { - return false; - } - - User leader = leaders.get(0).getUser(); - return saasUserExtensionService.isMeteredBillingEnabled(leader); - } catch (Exception e) { - log.error("Error checking team leader metered billing: {}", e.getMessage()); - return false; - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/model/CreditConsumptionResult.java b/app/saas/src/main/java/stirling/software/saas/model/CreditConsumptionResult.java deleted file mode 100644 index 14a449b04..000000000 --- a/app/saas/src/main/java/stirling/software/saas/model/CreditConsumptionResult.java +++ /dev/null @@ -1,69 +0,0 @@ -package stirling.software.saas.model; - -import lombok.AllArgsConstructor; -import lombok.Data; - -/** - * Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the - * operation succeeded and which credit source was used. - */ -@Data -@AllArgsConstructor -public class CreditConsumptionResult { - - /** Whether the credit consumption succeeded */ - private boolean success; - - /** - * The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with - * unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit - * allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION" - * (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message - * for reason). - */ - private String source; - - /** Human-readable message about the result */ - private String message; - - /** - * Creates a successful result for unlimited access (Pro plan UI requests). - * - * @param source The credit source (typically "PRO_PLAN") - * @return CreditConsumptionResult indicating unlimited access - */ - public static CreditConsumptionResult unlimited(String source) { - return new CreditConsumptionResult(true, source, "Unlimited access"); - } - - /** - * Creates a successful result for credit consumption. - * - * @param source The credit source used - * @return CreditConsumptionResult indicating success - */ - public static CreditConsumptionResult success(String source) { - return new CreditConsumptionResult(true, source, "Credits consumed"); - } - - /** - * Creates a failure result. - * - * @param reason The reason for failure - * @return CreditConsumptionResult indicating failure - */ - public static CreditConsumptionResult failure(String reason) { - return new CreditConsumptionResult(false, null, reason); - } - - /** - * Creates a failure result with custom message. - * - * @param reason The reason code - * @param message Custom human-readable message - * @return CreditConsumptionResult indicating failure - */ - public static CreditConsumptionResult failure(String reason, String message) { - return new CreditConsumptionResult(false, null, message != null ? message : reason); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/model/TeamCredit.java b/app/saas/src/main/java/stirling/software/saas/model/TeamCredit.java deleted file mode 100644 index 661cda1d4..000000000 --- a/app/saas/src/main/java/stirling/software/saas/model/TeamCredit.java +++ /dev/null @@ -1,131 +0,0 @@ -package stirling.software.saas.model; - -import java.io.Serializable; -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; -import org.hibernate.annotations.UpdateTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.Version; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import stirling.software.proprietary.model.Team; - -/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */ -@Entity -@Table(name = "team_credits") -@NoArgsConstructor -@Getter -@Setter -public class TeamCredit implements Serializable { - - private static final long serialVersionUID = 1L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "credit_id") - private Long id; - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_id", nullable = false, unique = true) - @OnDelete(action = OnDeleteAction.CASCADE) - private Team team; - - @Column(name = "cycle_credits_remaining") - private Integer cycleCreditsRemaining = 0; - - @Column(name = "cycle_credits_allocated") - private Integer cycleCreditsAllocated = 0; - - @Column(name = "bought_credits_remaining") - private Integer boughtCreditsRemaining = 0; - - @Column(name = "total_bought_credits") - private Integer totalBoughtCredits = 0; - - @Column(name = "last_cycle_reset_at") - private LocalDateTime lastCycleResetAt; - - @Column(name = "last_api_usage") - private LocalDateTime lastApiUsage; - - @Column(name = "total_api_calls_made") - private Long totalApiCallsMade = 0L; - - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - @Version - @Column(name = "version") - private Long version; - - public TeamCredit(Team team) { - this.team = team; - } - - public int getTotalAvailableCredits() { - return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0) - + (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0); - } - - public boolean hasCreditsAvailable() { - return getTotalAvailableCredits() > 0; - } - - /** - * Consume a credit from the team pool. Consumes cycle credits first, then bought credits. - * - * @return true if a credit was consumed, false if no credits available - */ - public boolean consumeCredit() { - if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) { - cycleCreditsRemaining--; - totalApiCallsMade++; - lastApiUsage = LocalDateTime.now(); - return true; - } else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) { - boughtCreditsRemaining--; - totalApiCallsMade++; - lastApiUsage = LocalDateTime.now(); - return true; - } - return false; - } - - public void addBoughtCredits(int credits) { - if (credits > 0) { - boughtCreditsRemaining = - (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits; - totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits; - } - } - - public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) { - this.cycleCreditsAllocated = cycleAllocation; - this.cycleCreditsRemaining = cycleAllocation; - this.lastCycleResetAt = resetTime; - } - - public boolean isCycleResetDue(LocalDateTime lastScheduledReset) { - return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/model/UserCredit.java b/app/saas/src/main/java/stirling/software/saas/model/UserCredit.java deleted file mode 100644 index 4a947df30..000000000 --- a/app/saas/src/main/java/stirling/software/saas/model/UserCredit.java +++ /dev/null @@ -1,133 +0,0 @@ -package stirling.software.saas.model; - -import java.io.Serializable; -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.OnDelete; -import org.hibernate.annotations.OnDeleteAction; -import org.hibernate.annotations.UpdateTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.Version; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import stirling.software.proprietary.security.model.User; - -/** - * Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over - * a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first. - */ -@Entity -@Table(name = "user_credits") -@NoArgsConstructor -@Getter -@Setter -public class UserCredit implements Serializable { - - private static final long serialVersionUID = 1L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "credit_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - @OnDelete(action = OnDeleteAction.CASCADE) - private User user; - - @Column(name = "cycle_credits_remaining") - private Integer cycleCreditsRemaining = 0; - - @Column(name = "cycle_credits_allocated") - private Integer cycleCreditsAllocated = 0; - - @Column(name = "bought_credits_remaining") - private Integer boughtCreditsRemaining = 0; - - @Column(name = "total_bought_credits") - private Integer totalBoughtCredits = 0; - - @Column(name = "last_cycle_reset_at") - private LocalDateTime lastCycleResetAt; - - @Column(name = "last_api_usage") - private LocalDateTime lastApiUsage; - - @Column(name = "total_api_calls_made") - private Long totalApiCallsMade = 0L; - - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - @Version - @Column(name = "version") - private Long version; - - public UserCredit(User user) { - this.user = user; - // Cycle credits are initialized by CreditService after this object is created, - // typically during user registration or at the start of a new billing cycle, - // using values from the application configuration. - } - - public int getTotalAvailableCredits() { - return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0) - + (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0); - } - - public boolean hasCreditsAvailable() { - return getTotalAvailableCredits() > 0; - } - - public boolean consumeCredit() { - // Consume cycle credits first, then bought credits. - if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) { - cycleCreditsRemaining--; - totalApiCallsMade++; - lastApiUsage = LocalDateTime.now(); - return true; - } else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) { - boughtCreditsRemaining--; - totalApiCallsMade++; - lastApiUsage = LocalDateTime.now(); - return true; - } - return false; - } - - public void addBoughtCredits(int credits) { - if (credits > 0) { - boughtCreditsRemaining = - (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits; - totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits; - } - } - - public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) { - this.cycleCreditsAllocated = cycleAllocation; - this.cycleCreditsRemaining = cycleAllocation; - this.lastCycleResetAt = resetTime; - } - - public boolean isCycleResetDue(LocalDateTime lastScheduledReset) { - return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index 42964e206..ea3bb9cda 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -51,10 +51,9 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry; * The real-charging path lives in a separate follow-up and reuses the same orchestration — only the * side-effect (shadow row vs ledger entry + Stripe call) differs. * - *

The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0} here. When the - * legacy {@code CreditService} is wired to call this service (separate PR), the legacy debit amount - * becomes available and {@code diffPct} can be computed against it; until then the shadow row - * captures the PAYG units only. + *

The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0}: the legacy + * credit engine has been removed, so there is no legacy debit to compare against and {@code + * diffPct} stays {@code 0}. The shadow row captures the PAYG units only. */ @Service @Profile("saas") @@ -281,8 +280,7 @@ public class JobChargeService { // Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed, // and a refund restores freeUnitsConsumed to the team's grant. row.setFreeUnitsConsumed(freeUnitsConsumed); - // No legacy comparison yet — wired when the shadow path is connected to the legacy - // CreditService in the follow-up PR. Until then, diff stays at 0. + // No legacy comparison: the legacy credit engine has been removed, so diff stays at 0. row.setLegacyCreditsCharged(0); row.setDiffPct(0); row.setStatus(ShadowChargeStatus.CHARGED); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index 6989c4e3b..eda04a478 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -53,9 +53,7 @@ import stirling.software.saas.payg.model.ProcessType; import stirling.software.saas.util.AuthenticationUtils; /** - * The hot-path PAYG interceptor. Mirrors the {@code UnifiedCreditInterceptor} shape: registered - * after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste - * work hashing inputs. + * The hot-path PAYG interceptor, registered in {@code PaygWebMvcConfig}. * *

{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the * latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the 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 bce659235..6d621ddce 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 @@ -18,10 +18,8 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard; *

  • {@link PaygResponseBodyWrapperFilter} as a Servlet filter — registered with no explicit * order so it sits at the end of the Spring filter chain (after all security filters). Pure * response-wrapping plumbing. - *
  • {@link PaygChargeInterceptor} as a Spring MVC interceptor — registered AFTER {@code - * UnifiedCreditInterceptor} so legacy credit rejections short-circuit before we hash inputs. - * Both intercept {@code /api/**} with the same admin/info/health exclusions as the legacy - * config. + *
  • {@link PaygChargeInterceptor} as a Spring MVC interceptor — intercepts {@code /api/**} with + * admin/info/health exclusions. * */ @Configuration @@ -42,10 +40,9 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { } /** - * 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. + * The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard}, 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; @@ -57,9 +54,7 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { * 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. + * compensating after the fact. */ public static final int ENTITLEMENT_GUARD_ORDER = 900; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamCreditRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamCreditRepository.java deleted file mode 100644 index 3757ce60d..000000000 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamCreditRepository.java +++ /dev/null @@ -1,68 +0,0 @@ -package stirling.software.saas.repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import stirling.software.saas.model.TeamCredit; - -@Repository -public interface TeamCreditRepository extends JpaRepository { - - /** Find team credits by team ID. */ - @Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId") - Optional findByTeamId(@Param("teamId") Long teamId); - - /** - * Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link - * TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than - * over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict. - */ - @Modifying - @Query( - value = - """ - UPDATE team_credits - SET cycle_credits_remaining = CASE - WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount - WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining) - THEN 0 - ELSE cycle_credits_remaining - END, - bought_credits_remaining = CASE - WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining - WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining) - THEN bought_credits_remaining - (:amount - cycle_credits_remaining) - WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount - THEN bought_credits_remaining - :amount - ELSE bought_credits_remaining - END, - total_api_calls_made = total_api_calls_made + :amount, - last_api_usage = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP, - version = version + 1 - WHERE team_id = :teamId - AND (cycle_credits_remaining + bought_credits_remaining) >= :amount - """, - nativeQuery = true) - int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount); - - @Query( - "SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId") - boolean existsByTeamId(@Param("teamId") Long teamId); - - @Modifying - @Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId") - void deleteByTeamId(@Param("teamId") Long teamId); - - @Query( - "SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset") - List findCreditsNeedingCycleReset( - @Param("lastScheduledReset") LocalDateTime lastScheduledReset); -} diff --git a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java deleted file mode 100644 index c01f3d034..000000000 --- a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java +++ /dev/null @@ -1,148 +0,0 @@ -package stirling.software.saas.repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.UserCredit; - -/** - * JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native - * SQL for atomic credit-consumption updates (avoids select-then-update races). - * - *

    Native queries reference {@code user_credits} and {@code users} unqualified — they pick up - * Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code - * application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is - * a one-property change instead of a sweep of native SQL. - */ -@Repository -public interface UserCreditRepository extends JpaRepository { - - Optional findByUser(User user); - - Optional findByUserId(Long userId); - - @Query( - "SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset") - List findCreditsNeedingCycleReset( - @Param("lastScheduledReset") LocalDateTime lastScheduledReset); - - @Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc") - Long getTotalApiCallsAcrossAllUsers(); - - @Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc") - Long getTotalAvailableCreditsAcrossAllUsers(); - - @Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey") - Optional findByUserApiKey(@Param("apiKey") String apiKey); - - @Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId") - Optional findBySupabaseId(@Param("supabaseId") UUID supabaseId); - - @Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since") - Long countActiveUsersInPeriod(@Param("since") LocalDateTime since); - - @Modifying - @Query( - value = - "UPDATE user_credits " - + "SET " - + " cycle_credits_remaining = " - + " CASE " - + " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount " - + " ELSE 0 " - + " END, " - + " bought_credits_remaining = " - + " CASE " - + " WHEN cycle_credits_remaining < :creditAmount " - + " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) " - + " ELSE bought_credits_remaining " - + " END, " - + " total_api_calls_made = total_api_calls_made + 1, " - + " last_api_usage = now() " - + "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) " - + " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)", - nativeQuery = true) - int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount); - - @Modifying - @Query( - value = - "UPDATE user_credits " - + "SET " - + " cycle_credits_remaining = " - + " CASE " - + " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount " - + " ELSE 0 " - + " END, " - + " bought_credits_remaining = " - + " CASE " - + " WHEN cycle_credits_remaining < :creditAmount " - + " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) " - + " ELSE bought_credits_remaining " - + " END, " - + " total_api_calls_made = total_api_calls_made + 1, " - + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " - + " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)", - nativeQuery = true) - int consumeCreditBySupabaseId( - @Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount); - - /** - * Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall - * logic. - */ - @Modifying - @Query( - value = - "UPDATE user_credits " - + "SET " - + " cycle_credits_remaining = cycle_credits_remaining - :amount, " - + " total_api_calls_made = total_api_calls_made + 1, " - + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " - + " AND cycle_credits_remaining >= :amount", - nativeQuery = true) - int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); - - /** Consumes ONLY bought credits (does not touch cycle credits). */ - @Modifying - @Query( - value = - "UPDATE user_credits " - + "SET " - + " bought_credits_remaining = bought_credits_remaining - :amount, " - + " total_api_calls_made = total_api_calls_made + 1, " - + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " - + " AND bought_credits_remaining >= :amount", - nativeQuery = true) - int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); - - /** Checks if user has sufficient cycle credits (does NOT consume them). */ - @Query( - value = - "SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END " - + "FROM user_credits uc " - + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)", - nativeQuery = true) - Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); - - /** Checks if user has sufficient bought credits (does NOT consume them). */ - @Query( - value = - "SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END " - + "FROM user_credits uc " - + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)", - nativeQuery = true) - Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); -} diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 5c97bd179..f1b2681e6 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -63,7 +63,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { private final TeamService teamService; private final UserService userService; private final SupabaseUserService supabaseUserService; - private final stirling.software.saas.service.CreditService creditService; private final SaasTeamService saasTeamService; private final JwtDecoder jwtDecoder; private final AuthenticationEntryPoint authenticationEntryPoint = @@ -73,13 +72,11 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { TeamService teamService, UserService userService, SupabaseUserService supabaseUserService, - stirling.software.saas.service.CreditService creditService, SaasTeamService saasTeamService, JwtDecoder jwtDecoder) { this.teamService = teamService; this.userService = userService; this.supabaseUserService = supabaseUserService; - this.creditService = creditService; this.saasTeamService = saasTeamService; this.jwtDecoder = jwtDecoder; } @@ -381,16 +378,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { // Only the DB-race winner runs first-time init; the losers skip it. if (weCreatedThisUser) { - try { - creditService.getOrCreateUserCredits(savedUser); - } catch (Exception e) { - log.warn( - "Failed to initialize credits for new user {} ({}): {}", - LogRedactionUtils.redactSupabaseId(supabaseId), - LogRedactionUtils.redactEmail(savedUser.getUsername()), - e.getMessage()); - } - try { saasTeamService.createPersonalTeam(savedUser); savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser); diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 0bccbf0b7..c0a78d56e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -49,7 +49,6 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.service.CreditService; import stirling.software.saas.service.SaasTeamService; import stirling.software.saas.service.SupabaseUserService; @@ -66,7 +65,6 @@ public class SupabaseSecurityConfig { private final UserService userService; private final TeamService teamService; private final SupabaseUserService supabaseUserService; - private final CreditService creditService; private final SaasTeamService saasTeamService; private final ApplicationProperties applicationProperties; @@ -121,7 +119,6 @@ public class SupabaseSecurityConfig { teamService, userService, supabaseUserService, - creditService, saasTeamService, jwtDecoder), BearerTokenAuthenticationFilter.class) @@ -300,7 +297,7 @@ public class SupabaseSecurityConfig { "Accept", "Origin", "X-API-KEY")); - cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining")); + cfg.setExposedHeaders(List.of("WWW-Authenticate")); cfg.setAllowCredentials(true); cfg.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java b/app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java deleted file mode 100644 index 6812fb358..000000000 --- a/app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java +++ /dev/null @@ -1,67 +0,0 @@ -package stirling.software.saas.service; - -import java.time.LocalDateTime; -import java.time.ZoneId; - -import org.springframework.context.annotation.Profile; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.saas.config.CreditsProperties; - -@Service -@Profile("saas") -@Slf4j -@RequiredArgsConstructor -public class CreditResetScheduler { - - private final CreditService creditService; - private final CreditsProperties creditsProperties; - - /** - * Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs - * monthly, resetting credits based on user roles and team seats - */ - @Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}") - public void resetCycleCredits() { - log.info( - "Starting monthly credit reset for all users and teams (schedule: {}, zone: {})", - creditsProperties.getReset().getCron(), - creditsProperties.getReset().getZone()); - - try { - ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone()); - LocalDateTime resetTime = LocalDateTime.now(configuredZone); - creditService.resetCycleCreditsForAllUsers(resetTime); - creditService.resetCycleCreditsForAllTeams(resetTime); - log.info("Monthly credit reset completed successfully at {}", resetTime); - } catch (Exception e) { - log.error("Error during monthly credit reset", e); - } - } - - // NOTE: The startup catch-up reset (formerly @EventListener(ApplicationReadyEvent)) was - // removed. It bulk-looped every user on each boot (per-row save), hammering the DB and - // stalling boot on large user tables. Per-user cycle resets already happen lazily in - // CreditService.getOrCreateUserCredits (isCycleResetDue), and the monthly cron above still - // performs the scheduled reset. - - /** - * Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like - * cleaning up old data. - */ - @Scheduled(cron = "0 0 3 * * *", zone = "UTC") - public void performDailyMaintenance() { - log.debug("Starting daily credit system maintenance"); - - try { - // API call history cleanup is no longer needed; audit system handles this - log.debug("Daily credit system maintenance completed"); - } catch (Exception e) { - log.error("Error during daily credit system maintenance", e); - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/service/CreditService.java b/app/saas/src/main/java/stirling/software/saas/service/CreditService.java deleted file mode 100644 index 775c97d8d..000000000 --- a/app/saas/src/main/java/stirling/software/saas/service/CreditService.java +++ /dev/null @@ -1,1179 +0,0 @@ -package stirling.software.saas.service; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import org.slf4j.MDC; -import org.springframework.context.annotation.Profile; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.billing.service.StripeUsageReportingService; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.repository.TeamCreditRepository; -import stirling.software.saas.repository.UserCreditRepository; -import stirling.software.saas.util.LogRedactionUtils; - -@Service -@Profile("saas") -@Slf4j -@Transactional -public class CreditService { - - private final UserCreditRepository userCreditRepository; - private final TeamCreditRepository teamCreditRepository; - private final UserRepository userRepository; - private final UserService userService; - private final CreditsProperties creditsProperties; - private final TeamCreditService teamCreditService; - private final StripeUsageReportingService stripeUsageReportingService; - private final SaasUserExtensionService saasUserExtensionService; - private final SaasTeamExtensionService saasTeamExtensionService; - - // Telemetry metrics - private final Counter creditsConsumedCounter; - private final Counter creditConsumptionFailuresCounter; - private final Counter cycleResetCounter; - private final Counter stripeReportFailuresCounter; - - public CreditService( - UserCreditRepository userCreditRepository, - TeamCreditRepository teamCreditRepository, - UserRepository userRepository, - UserService userService, - CreditsProperties creditsProperties, - TeamCreditService teamCreditService, - StripeUsageReportingService stripeUsageReportingService, - SaasUserExtensionService saasUserExtensionService, - SaasTeamExtensionService saasTeamExtensionService, - MeterRegistry meterRegistry) { - this.userCreditRepository = userCreditRepository; - this.teamCreditRepository = teamCreditRepository; - this.userRepository = userRepository; - this.userService = userService; - this.creditsProperties = creditsProperties; - this.teamCreditService = teamCreditService; - this.stripeUsageReportingService = stripeUsageReportingService; - this.saasUserExtensionService = saasUserExtensionService; - this.saasTeamExtensionService = saasTeamExtensionService; - - // Initialize metrics - this.creditsConsumedCounter = - Counter.builder("credits.consumed") - .description("Number of credits consumed") - .register(meterRegistry); - this.creditConsumptionFailuresCounter = - Counter.builder("credits.consumption.failures") - .description("Number of failed credit consumption attempts") - .register(meterRegistry); - this.cycleResetCounter = - Counter.builder("credits.cycle_reset") - .description("Number of credit cycle resets performed") - .register(meterRegistry); - this.stripeReportFailuresCounter = - Counter.builder("credits.stripe_report.failures") - .description("Stripe meter post failed after the DB debit committed") - .register(meterRegistry); - - // Active gauges for current credit levels - Gauge.builder("credits.total_available", this, CreditService::getTotalAvailableCredits) - .description("Total credits available across all users") - .register(meterRegistry); - Gauge.builder("credits.total_api_calls", this, CreditService::getTotalApiCalls) - .description("Total API calls made across all users") - .register(meterRegistry); - } - - public Optional getUserCreditsByApiKey(String apiKey) { - return userCreditRepository.findByUserApiKey(apiKey); - } - - public Optional getUserCreditsBySupabaseId(String supabaseId) { - try { - UUID supabaseUuid = UUID.fromString(supabaseId); - return userCreditRepository.findBySupabaseId(supabaseUuid); - } catch (IllegalArgumentException e) { - log.warn("Invalid Supabase ID format: {}", supabaseId); - return Optional.empty(); - } - } - - public Optional getUserBySupabaseId(UUID supabaseId) { - return userService.findBySupabaseId(supabaseId); - } - - public Optional getUserCreditsFromAuthentication() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - return Optional.empty(); - } - - if (authentication instanceof ApiKeyAuthenticationToken) { - // API Key authentication: credit limits apply - User user = (User) authentication.getPrincipal(); - return getUserCreditsByUserId(user.getId()); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // JWT/Session authentication: unlimited for frontend users - User user = (User) authentication.getPrincipal(); - return getUserCreditsByUserId(user.getId()); - } - - return Optional.empty(); - } - - public Optional getUserCreditsByUserId(Long userId) { - return userCreditRepository.findByUserId(userId); - } - - public UserCredit getOrCreateUserCredits(User user) { - Optional existing = userCreditRepository.findByUser(user); - if (existing.isPresent()) { - UserCredit credits = existing.get(); - // Check if cycle reset is needed based on last scheduled reset - LocalDateTime lastScheduledReset = getMostRecentScheduledReset(); - if (credits.isCycleResetDue(lastScheduledReset)) { - int allocation = getCycleAllocationForUser(user); - credits.resetCycleCredits(allocation, lastScheduledReset); - return userCreditRepository.save(credits); - } - return credits; - } - - // Create new credits for user with proper allocation - UserCredit newCredits = new UserCredit(user); - int allocation = getCycleAllocationForUser(user); - newCredits.resetCycleCredits(allocation, LocalDateTime.now()); - return userCreditRepository.save(newCredits); - } - - private LocalDateTime getMostRecentScheduledReset() { - ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone()); - LocalDateTime now = LocalDateTime.now(configuredZone); - ZonedDateTime zonedNow = now.atZone(configuredZone); - - // Extract hour from cron expression (format: "0 0 2 1 * *" -> hour is 2) - String cronExpression = creditsProperties.getReset().getCron(); - int resetHour = extractHourFromCron(cronExpression); - - // Find first day of current month at the configured time - ZonedDateTime firstOfMonth = - zonedNow.withDayOfMonth(1) - .withHour(resetHour) - .withMinute(0) - .withSecond(0) - .withNano(0); - - // If we're on the 1st but before the reset hour, use previous month's first day - if (zonedNow.getDayOfMonth() == 1 && zonedNow.getHour() < resetHour) { - firstOfMonth = firstOfMonth.minusMonths(1); - } - // If we're before the 1st of this month, use previous month's first day - else if (zonedNow.isBefore(firstOfMonth)) { - firstOfMonth = firstOfMonth.minusMonths(1); - } - - return firstOfMonth.toLocalDateTime(); - } - - private int extractHourFromCron(String cronExpression) { - try { - // Cron format: "second minute hour day month weekday" - String[] parts = cronExpression.split("\\s+"); - if (parts.length >= 3) { - return Integer.parseInt(parts[2]); - } - } catch (NumberFormatException e) { - log.warn( - "Failed to parse hour from cron expression '{}', using default 2", - cronExpression); - } - return 2; // Default to 2 AM - } - - public boolean hasCreditsAvailable(String apiKey) { - Optional credits = getUserCreditsByApiKey(apiKey); - if (credits.isPresent()) { - return credits.get().hasCreditsAvailable(); - } - - // Lazy create UserCredit for existing users who don't have rows yet - Optional userOpt = userRepository.findByApiKey(apiKey); - if (userOpt.isPresent()) { - User user = userOpt.get(); - UserCredit newCredits = getOrCreateUserCredits(user); - return newCredits.hasCreditsAvailable(); - } - - // No user found with this API key - return false; - } - - public boolean consumeCredit(String apiKey, int creditAmount) { - int rowsUpdated = userCreditRepository.consumeCredit(apiKey, creditAmount); - - if (rowsUpdated == 1) { - creditsConsumedCounter.increment(creditAmount); - if (log.isTraceEnabled()) { - log.trace("{} credits consumed for API key: {}", creditAmount, maskApiKey(apiKey)); - } - return true; - } - - creditConsumptionFailuresCounter.increment(); - log.warn( - "Credit consumption failed for API key: {} - insufficient credits (requested: {})", - maskApiKey(apiKey), - creditAmount); - return false; - } - - /** Consume credits for a user identified by Supabase ID; metered overage bills to Stripe. */ - public boolean consumeCreditBySupabaseId(String supabaseId, int creditAmount) { - try { - UUID supabaseUuid = UUID.fromString(supabaseId); - log.debug( - "[CREDIT-CONSUME] Starting credit consumption for Supabase ID: {}, amount: {}", - supabaseId, - creditAmount); - - // Check if user is usage-based - Optional userOpt = userService.findBySupabaseId(supabaseUuid); - - if (userOpt.isEmpty()) { - log.error("[CREDIT-CONSUME] User not found for Supabase ID: {}", supabaseId); - creditConsumptionFailuresCounter.increment(); - return false; - } - - User user = userOpt.get(); - boolean isUsageBased = hasMeteredBillingEnabled(user); - log.info( - "[CREDIT-CONSUME] User {} - Metered billing enabled: {}, Roles: {}", - user.getUsername(), - isUsageBased, - user.getRolesAsString()); - - if (isUsageBased) { - // Metered billing: Try to consume free credits first, then report overage to Stripe - UserCredit userCredits = getOrCreateUserCredits(user); - - log.info( - "[CREDIT-CONSUME] Metered billing user detected: {} - Cycle credits remaining: {}, Amount needed: {}", - user.getUsername(), - userCredits.getCycleCreditsRemaining(), - creditAmount); - - if (userCredits.getCycleCreditsRemaining() >= creditAmount) { - // Covered by free tier; consume normally - int rowsUpdated = - userCreditRepository.consumeCreditBySupabaseId( - supabaseUuid, creditAmount); - - if (rowsUpdated == 1) { - creditsConsumedCounter.increment(creditAmount); - if (log.isTraceEnabled()) { - log.trace( - "[USAGE-BASED] {} credits consumed from free tier for user: {}", - creditAmount, - supabaseId); - } - return true; - } - } else { - // Partial or full overage: consume free credits in this tx, report the overage - // to Stripe after commit (see scheduleStripeReportAfterCommit). - int freeCreditsUsed = - userCredits.getCycleCreditsRemaining() != null - ? userCredits.getCycleCreditsRemaining() - : 0; - int overageCredits = creditAmount - freeCreditsUsed; - - log.warn( - "[CREDIT-CONSUME] OVERAGE DETECTED for user: {} - Free credits available: {}, Credits needed: {}, Overage: {}", - user.getUsername(), - freeCreditsUsed, - creditAmount, - overageCredits); - - // Consume available free credits (if any) - if (freeCreditsUsed > 0) { - log.debug( - "[CREDIT-CONSUME] Consuming {} free credits first", - freeCreditsUsed); - int rowsUpdated = - userCreditRepository.consumeCreditBySupabaseId( - supabaseUuid, freeCreditsUsed); - if (rowsUpdated != 1) { - log.warn( - "[USAGE-BASED] Failed to consume {} free credits for user: {}", - freeCreditsUsed, - supabaseId); - creditConsumptionFailuresCounter.increment(); - return false; - } - } - - String operationId = MDC.get("requestId"); - String idempotencyKey = - stripeUsageReportingService.generateIdempotencyKey( - supabaseId, overageCredits, operationId); - - scheduleStripeReportAfterCommit( - supabaseId, - overageCredits, - idempotencyKey, - creditAmount, - freeCreditsUsed); - return true; - } - - // Lost a concurrent-debit race: the in-memory balance check passed but the atomic - // UPDATE found insufficient credits. Surface the failure so the caller can retry. - log.warn( - "[USAGE-BASED] Concurrent-debit race lost the free-tier consumption for" - + " user {}; caller should retry.", - supabaseId); - creditConsumptionFailuresCounter.increment(); - return false; - } - - // Existing prepaid logic for Pro/Credit-Based users - log.debug( - "[CREDIT-CONSUME] Non-usage-based user; using standard prepaid credit consumption"); - int rowsUpdated = - userCreditRepository.consumeCreditBySupabaseId(supabaseUuid, creditAmount); - - if (rowsUpdated == 1) { - creditsConsumedCounter.increment(creditAmount); - if (log.isTraceEnabled()) { - log.trace("{} credits consumed for Supabase ID: {}", creditAmount, supabaseId); - } - log.debug( - "[CREDIT-CONSUME] Standard credit consumption successful for user: {}", - supabaseId); - return true; - } - - creditConsumptionFailuresCounter.increment(); - log.warn( - "[CREDIT-CONSUME] Credit consumption failed for Supabase ID: {} - insufficient credits (requested: {})", - supabaseId, - creditAmount); - return false; - } catch (IllegalArgumentException e) { - log.error( - "[CREDIT-CONSUME] Invalid Supabase ID format: {} - cannot consume credits", - supabaseId, - e); - creditConsumptionFailuresCounter.increment(); - return false; - } catch (RuntimeException e) { - log.error( - "[CREDIT-CONSUME] Unexpected runtime error consuming credits for user: {} - {}", - supabaseId, - e.getMessage(), - e); - creditConsumptionFailuresCounter.increment(); - return false; - } catch (Exception e) { - log.error( - "[CREDIT-CONSUME] Unexpected error consuming credits for user: {} - {}", - supabaseId, - e.getMessage(), - e); - creditConsumptionFailuresCounter.increment(); - return false; - } - } - - /** - * Checks if a user has metered billing enabled. For users with metered billing, credits are - * billed on usage through Stripe metering rather than being allocated on a monthly cycle basis. - * - * @param user User to check - * @return true if the user has metered billing enabled, false otherwise - */ - private boolean hasMeteredBillingEnabled(User user) { - return saasUserExtensionService.isMeteredBillingEnabled(user); - } - - /** - * Posts the Stripe meter event for an overage debit in a {@code TransactionSynchronization} - * afterCommit hook, so the DB row lock is released before the HTTP call to Stripe. - * - *

    If no transaction is active (e.g. a test calling consume directly) the report runs - * synchronously instead, so the meter event still fires. - */ - private void scheduleStripeReportAfterCommit( - String supabaseId, - int overageCredits, - String idempotencyKey, - int creditAmount, - int freeCreditsUsed) { - - Runnable reportToStripe = - () -> { - log.info( - "[CREDIT-CONSUME] Posting Stripe meter event - User: {}, Overage: {}," - + " Idempotency: {}", - supabaseId, - overageCredits, - idempotencyKey); - - boolean reported; - try { - reported = - stripeUsageReportingService.reportUsageToStripe( - supabaseId, overageCredits, idempotencyKey); - } catch (RuntimeException e) { - // Don't let a Stripe exception unwind the afterCommit chain — the DB - // debit has already committed. - log.error( - "[CREDIT-CONSUME] Stripe meter post threw for user {} (overage {});" - + " usage owed-but-unbilled until a retry succeeds", - supabaseId, - overageCredits, - e); - stripeReportFailuresCounter.increment(); - return; - } - - if (reported) { - creditsConsumedCounter.increment(creditAmount); - log.info( - "[USAGE-BASED] User {} consumed {} free + {} overage credits" - + " (total: {}); Stripe meter posted.", - supabaseId, - freeCreditsUsed, - overageCredits, - creditAmount); - } else { - // DB has the debit, Stripe doesn't. The idempotency key is stable, so a - // replay with the same key recovers the meter event without - // double-charging. - stripeReportFailuresCounter.increment(); - log.error( - "[USAGE-BASED] Failed to post Stripe meter event for user {}" - + " (overage {}); usage owed-but-unbilled. Idempotency key" - + " is stable: replay with key '{}' to recover.", - supabaseId, - overageCredits, - idempotencyKey); - } - }; - - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - reportToStripe.run(); - } - }); - } else { - log.warn( - "[CREDIT-CONSUME] No active transaction; reporting Stripe usage synchronously." - + " Expected only in tests."); - reportToStripe.run(); - } - } - - /** Check if a user has credits available by Supabase ID (unified approach). */ - public boolean hasCreditsAvailableBySupabaseId(String supabaseId) { - Optional credits = getUserCreditsBySupabaseId(supabaseId); - if (credits.isPresent()) { - return credits.get().hasCreditsAvailable(); - } - - // Lazy create UserCredit for existing users who don't have rows yet - try { - UUID supabaseUuid = UUID.fromString(supabaseId); - Optional userOpt = userService.findBySupabaseId(supabaseUuid); - if (userOpt.isPresent()) { - User user = userOpt.get(); - UserCredit newCredits = getOrCreateUserCredits(user); - return newCredits.hasCreditsAvailable(); - } - } catch (IllegalArgumentException e) { - log.warn("Invalid Supabase ID format: {}", supabaseId); - } - - // No user found with this Supabase ID - return false; - } - - public boolean isApiKeyAuthenticated() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return authentication instanceof ApiKeyAuthenticationToken; - } - - public boolean isJwtAuthenticated() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return authentication instanceof UsernamePasswordAuthenticationToken; - } - - public void addBoughtCredits(String username, int credits) { - Optional userOpt = userRepository.findByUsername(username); - if (userOpt.isEmpty()) { - throw new IllegalArgumentException("User not found: " + username); - } - - User user = userOpt.get(); - UserCredit userCredits = getOrCreateUserCredits(user); - userCredits.addBoughtCredits(credits); - userCreditRepository.save(userCredits); - - log.info( - "Added {} bought credits to user: {}. Total available: {}", - credits, - username, - userCredits.getTotalAvailableCredits()); - } - - public void setBoughtCredits(String username, int credits) { - Optional userOpt = userRepository.findByUsername(username); - if (userOpt.isEmpty()) { - throw new IllegalArgumentException("User not found: " + username); - } - User user = userOpt.get(); - UserCredit userCredits = getOrCreateUserCredits(user); - - int previousBought = userCredits.getBoughtCreditsRemaining(); - userCredits.setBoughtCreditsRemaining(credits); - userCredits.setTotalBoughtCredits(credits); // Also update total bought to match - - userCreditRepository.save(userCredits); - log.info( - "Set bought credits for user: {} from {} to {}. Total available: {}", - username, - previousBought, - credits, - userCredits.getTotalAvailableCredits()); - } - - public void setCycleCredits(String username, int credits) { - Optional userOpt = userRepository.findByUsername(username); - if (userOpt.isEmpty()) { - throw new IllegalArgumentException("User not found: " + username); - } - User user = userOpt.get(); - UserCredit userCredits = getOrCreateUserCredits(user); - - int previousCycle = userCredits.getCycleCreditsRemaining(); - userCredits.setCycleCreditsRemaining(credits); - - userCreditRepository.save(userCredits); - log.info( - "Set cycle credits for user: {} from {} to {}. Total available: {}", - username, - previousCycle, - credits, - userCredits.getTotalAvailableCredits()); - } - - public void addBoughtCreditsBySupabaseId(String supabaseId, int credits) { - UserCredit userCredits = getUserCreditsBySupabaseIdWithValidation(supabaseId); - userCredits.addBoughtCredits(credits); - userCreditRepository.save(userCredits); - - log.info( - "Added {} bought credits to user with Supabase ID: {}. Total available: {}", - credits, - supabaseId, - userCredits.getTotalAvailableCredits()); - } - - public void setBoughtCreditsBySupabaseId(String supabaseId, int credits) { - UserCredit userCredits = getUserCreditsBySupabaseIdWithValidation(supabaseId); - - int previousBought = userCredits.getBoughtCreditsRemaining(); - userCredits.setBoughtCreditsRemaining(credits); - userCredits.setTotalBoughtCredits(credits); // Also update total bought to match - - userCreditRepository.save(userCredits); - log.info( - "Set bought credits for user with Supabase ID: {} from {} to {}. Total available: {}", - supabaseId, - previousBought, - credits, - userCredits.getTotalAvailableCredits()); - } - - public void setCycleCreditsBySupabaseId(String supabaseId, int credits) { - UserCredit userCredits = getUserCreditsBySupabaseIdWithValidation(supabaseId); - - int previousCycle = userCredits.getCycleCreditsRemaining(); - userCredits.setCycleCreditsRemaining(credits); - - userCreditRepository.save(userCredits); - log.info( - "Set cycle credits for user with Supabase ID: {} from {} to {}. Total available: {}", - supabaseId, - previousCycle, - credits, - userCredits.getTotalAvailableCredits()); - } - - public void resetCycleCreditsForAllUsers(LocalDateTime lastScheduledReset) { - List creditsNeedingReset = - userCreditRepository.findCreditsNeedingCycleReset(lastScheduledReset); - - for (UserCredit credit : creditsNeedingReset) { - int allocation = getCycleAllocationForUser(credit.getUser()); - credit.resetCycleCredits(allocation, lastScheduledReset); - userCreditRepository.save(credit); - cycleResetCounter.increment(); - } - - log.info( - "Reset cycle credits for {} users based on scheduled reset time: {}", - creditsNeedingReset.size(), - lastScheduledReset); - } - - // Backward compatibility method - public void resetCycleCreditsForAllUsers() { - LocalDateTime now = LocalDateTime.now(); - resetCycleCreditsForAllUsers(now); - } - - public void resetCycleCreditsForAllTeams(LocalDateTime lastScheduledReset) { - List creditsNeedingReset = - teamCreditRepository.findCreditsNeedingCycleReset(lastScheduledReset); - - int proAllocation = - creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500); - int totalCycleAllocation = proAllocation; - - for (TeamCredit credit : creditsNeedingReset) { - credit.resetCycleCredits(totalCycleAllocation, lastScheduledReset); - teamCreditRepository.save(credit); - cycleResetCounter.increment(); - - log.info( - "Reset cycle credits for team {} to {} (fixed PRO amount)", - credit.getTeam().getId(), - totalCycleAllocation); - } - - log.info( - "Reset cycle credits for {} teams based on scheduled reset time: {}", - creditsNeedingReset.size(), - lastScheduledReset); - } - - // Backward compatibility method - public void resetCycleCreditsForAllTeams() { - LocalDateTime now = LocalDateTime.now(); - resetCycleCreditsForAllTeams(now); - } - - /** Credit summary keyed by API key; for API-key-only users without a linked Supabase ID. */ - public CreditSummary getCreditSummaryByApiKey(String apiKey) { - Optional creditsOpt = getUserCreditsByApiKey(apiKey); - if (creditsOpt.isEmpty()) { - return new CreditSummary(); - } - UserCredit credits = creditsOpt.get(); - boolean isUnlimited = credits.getCycleCreditsAllocated() == Integer.MAX_VALUE; - return new CreditSummary( - credits.getCycleCreditsRemaining(), - credits.getCycleCreditsAllocated(), - credits.getBoughtCreditsRemaining(), - credits.getTotalBoughtCredits(), - credits.getTotalAvailableCredits(), - credits.getLastCycleResetAt(), - credits.getLastApiUsage(), - isUnlimited); - } - - public CreditSummary getCreditSummary(String username) { - // Note: This method is kept for admin functions that need to lookup users by username. - Optional userOpt = userRepository.findByUsername(username); - if (userOpt.isEmpty()) { - log.warn("No user found with username: {}", username); - return new CreditSummary(); - } - - User user = userOpt.get(); - UserCredit credits = getOrCreateUserCredits(user); - boolean isUnlimited = credits.getCycleCreditsAllocated() == Integer.MAX_VALUE; - return new CreditSummary( - credits.getCycleCreditsRemaining(), - credits.getCycleCreditsAllocated(), - credits.getBoughtCreditsRemaining(), - credits.getTotalBoughtCredits(), - credits.getTotalAvailableCredits(), - credits.getLastCycleResetAt(), - credits.getLastApiUsage(), - isUnlimited); - } - - /** - * Credit summary for a user. Non-personal team members use the shared team pool; personal-team - * or teamless users use individual credits. - */ - public CreditSummary getCreditSummaryBySupabaseId(String supabaseId) { - // First, look up the user to check for team membership - UUID supabaseUuid; - try { - supabaseUuid = UUID.fromString(supabaseId); - } catch (IllegalArgumentException e) { - log.warn("Invalid Supabase ID format: {}", supabaseId); - return new CreditSummary(); - } - - Optional userOpt = userService.findBySupabaseId(supabaseUuid); - if (userOpt.isEmpty()) { - log.warn("No user found with Supabase ID: {}", supabaseId); - return new CreditSummary(); - } - - User user = userOpt.get(); - - // Check if user has LIMITED_API_USER role (anonymous/guest users). - // Limited API users always use personal credits, never team credits. - boolean isLimitedApiUser = - user.getAuthorities().stream() - .anyMatch( - authority -> - "ROLE_LIMITED_API_USER".equals(authority.getAuthority()) - || "ROLE_EXTRA_LIMITED_API_USER" - .equals(authority.getAuthority())); - - if (isLimitedApiUser) { - log.debug("User {} is limited API user; using personal credits", user.getUsername()); - } - // Check if user is on a non-personal team; if so, return team credits. - // Skip this check for limited API users. - else if (user.getTeam() != null && !saasTeamExtensionService.isPersonal(user.getTeam())) { - Long teamId = user.getTeam().getId(); - log.debug( - "User {} is on team {} - returning team credits instead of personal credits", - user.getUsername(), - teamId); - - Optional teamCreditsOpt = teamCreditService.getTeamCredits(teamId); - if (teamCreditsOpt.isPresent()) { - TeamCredit tc = teamCreditsOpt.get(); - return new CreditSummary( - tc.getCycleCreditsRemaining() != null ? tc.getCycleCreditsRemaining() : 0, - tc.getCycleCreditsAllocated() != null ? tc.getCycleCreditsAllocated() : 0, - tc.getBoughtCreditsRemaining() != null ? tc.getBoughtCreditsRemaining() : 0, - tc.getTotalBoughtCredits() != null ? tc.getTotalBoughtCredits() : 0, - tc.getTotalAvailableCredits(), - tc.getLastCycleResetAt(), - tc.getLastApiUsage(), - false // teams never have unlimited credits - ); - } else { - log.warn("Team {} exists but has no credit record; returning empty", teamId); - return new CreditSummary(); - } - } - - // User is limited API user, not on a team, or on a personal team; return personal credits - log.debug("User {} using personal credits", user.getUsername()); - Optional creditsOpt = getUserCreditsBySupabaseId(supabaseId); - if (creditsOpt.isEmpty()) { - // Lazy initialization: try to create UserCredit for existing user - log.info( - "UserCredit missing for Supabase ID {}, creating now", - LogRedactionUtils.redactSupabaseId(supabaseId)); - UserCredit newCredits = initializeCreditsForUser(user); - boolean isUnlimited = newCredits.getCycleCreditsAllocated() == Integer.MAX_VALUE; - return new CreditSummary( - newCredits.getCycleCreditsRemaining(), - newCredits.getCycleCreditsAllocated(), - newCredits.getBoughtCreditsRemaining(), - newCredits.getTotalBoughtCredits(), - newCredits.getTotalAvailableCredits(), - newCredits.getLastCycleResetAt(), - newCredits.getLastApiUsage(), - isUnlimited); - } - - UserCredit credits = creditsOpt.get(); - boolean isUnlimited = credits.getCycleCreditsAllocated() == Integer.MAX_VALUE; - return new CreditSummary( - credits.getCycleCreditsRemaining(), - credits.getCycleCreditsAllocated(), - credits.getBoughtCreditsRemaining(), - credits.getTotalBoughtCredits(), - credits.getTotalAvailableCredits(), - credits.getLastCycleResetAt(), - credits.getLastApiUsage(), - isUnlimited); - } - - /** Helper method to lookup user by Supabase ID with proper error handling */ - private UserCredit getUserCreditsBySupabaseIdWithValidation(String supabaseId) { - try { - UUID supabaseUuid = UUID.fromString(supabaseId); - Optional userOpt = userService.findBySupabaseId(supabaseUuid); - if (userOpt.isEmpty()) { - throw new IllegalArgumentException( - "User not found with Supabase ID: " + supabaseId); - } - - User user = userOpt.get(); - return getOrCreateUserCredits(user); - } catch (IllegalArgumentException e) { - if (e.getMessage().startsWith("Invalid UUID")) { - throw new IllegalArgumentException("Invalid Supabase ID format: " + supabaseId); - } - throw e; - } - } - - private String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.length() < 8) { - return "***"; - } - return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4); - } - - /** Get total available credits across all users (for metrics gauge) */ - private Double getTotalAvailableCredits() { - try { - Long total = userCreditRepository.getTotalAvailableCreditsAcrossAllUsers(); - return total != null ? total.doubleValue() : 0.0; - } catch (Exception e) { - log.error("Error calculating total available credits for metrics", e); - return 0.0; - } - } - - /** Get total API calls across all users (for metrics gauge) */ - private Double getTotalApiCalls() { - try { - Long total = userCreditRepository.getTotalApiCallsAcrossAllUsers(); - return total != null ? total.doubleValue() : 0.0; - } catch (Exception e) { - log.error("Error calculating total API calls for metrics", e); - return 0.0; - } - } - - /** Get cycle credit allocation for a user based on configuration */ - private int getCycleAllocationForUser(User user) { - if (user == null || user.getRolesAsString() == null) { - log.warn("User or roles is null, returning 0 credits"); - return 0; - } - - String rolesString = user.getRolesAsString(); - Map allocations = creditsProperties.getCycle().getAllocations(); - - log.debug( - "Getting credit allocation for user {} with roles: {}", - user.getUsername(), - rolesString); - log.debug("Available credit allocations: {}", allocations); - - // Check roles in priority order - if (rolesString.contains("ROLE_ADMIN") && creditsProperties.getCycle().isAdminUnlimited()) { - log.debug("User {} has admin unlimited credits", user.getUsername()); - return Integer.MAX_VALUE; - } - - // Internal API users (including test API key) get unlimited credits - if (rolesString.contains("ROLE_INTERNAL_API_USER")) { - log.debug("User {} has internal API unlimited credits", user.getUsername()); - return Integer.MAX_VALUE; - } - - for (Map.Entry entry : allocations.entrySet()) { - if (rolesString.contains(entry.getKey())) { - log.debug( - "User {} matched role {} with {} credits", - user.getUsername(), - entry.getKey(), - entry.getValue()); - return entry.getValue(); - } - } - - // Default allocation - int defaultCredits = allocations.getOrDefault("ROLE_USER", 50); - log.debug( - "User {} using default ROLE_USER allocation: {} credits", - user.getUsername(), - defaultCredits); - return defaultCredits; - } - - /** Initialize credits for a new user */ - public UserCredit initializeCreditsForUser(User user) { - log.info( - "Initializing credits for user: {} (id: {})", - LogRedactionUtils.redactEmail(user.getUsername()), - user.getId()); - UserCredit credits = new UserCredit(user); - int allocation = getCycleAllocationForUser(user); - log.info( - "Allocated {} credits to user: {}", - allocation, - LogRedactionUtils.redactEmail(user.getUsername())); - credits.resetCycleCredits(allocation, LocalDateTime.now()); - UserCredit saved = userCreditRepository.save(credits); - log.info( - "Successfully saved UserCredit for user: {} with allocation: {}", - LogRedactionUtils.redactEmail(user.getUsername()), - allocation); - return saved; - } - - /** - * Refresh cycle credits after a role change. Resets {@code cycleCreditsRemaining} to the new - * allocation; preserves {@code boughtCreditsRemaining}. - */ - public void refreshCreditsAfterRoleChange(User user) { - log.info( - "Refreshing credits for user: {} after role change", - LogRedactionUtils.redactEmail(user.getUsername())); - - Optional creditsOpt = userCreditRepository.findByUserId(user.getId()); - if (creditsOpt.isEmpty()) { - log.warn( - "No credits found for user {}, initializing", - LogRedactionUtils.redactEmail(user.getUsername())); - initializeCreditsForUser(user); - return; - } - - UserCredit credits = creditsOpt.get(); - int oldAllocation = credits.getCycleCreditsAllocated(); - int oldRemaining = credits.getCycleCreditsRemaining(); - int newAllocation = getCycleAllocationForUser(user); - - log.info( - "Updating credits for user {} from {}/{} to {}/{} cycle credits", - user.getUsername(), - oldRemaining, - oldAllocation, - newAllocation, - newAllocation); - - // Full reset: sets both allocation and remaining to the new amount. - // This gives full credits on upgrade, but removes excess on downgrade. - credits.resetCycleCredits(newAllocation, LocalDateTime.now()); - userCreditRepository.save(credits); - - log.info( - "Successfully refreshed credits for user {}: {} cycle credits available", - user.getUsername(), - newAllocation); - } - - /** - * Resets cycle credit allocation after a role change. More efficient version when the caller - * already knows the target allocation. Performs a FULL RESET: updates allocation, remaining, - * and timestamp. - * - *

    Different from setCycleCredits() which only adjusts remaining credits. - * - * @param userId The user ID - * @param newAllocation The new cycle credit allocation amount - */ - public void resetCycleAllocationForRoleChange(Long userId, int newAllocation) { - log.info("Resetting cycle allocation for user ID {} to {}", userId, newAllocation); - - Optional creditsOpt = userCreditRepository.findByUserId(userId); - if (creditsOpt.isEmpty()) { - log.warn("No credits found for user ID {}, cannot reset allocation", userId); - throw new IllegalStateException("User credits not found for user ID: " + userId); - } - - UserCredit credits = creditsOpt.get(); - int oldAllocation = credits.getCycleCreditsAllocated(); - int oldRemaining = credits.getCycleCreditsRemaining(); - - log.info( - "Resetting allocation for user ID {} from {}/{} to {}/{} cycle credits", - userId, - oldRemaining, - oldAllocation, - newAllocation, - newAllocation); - - // Full reset: sets both allocation and remaining to the new amount - credits.resetCycleCredits(newAllocation, LocalDateTime.now()); - userCreditRepository.save(credits); - - log.info( - "Successfully reset cycle allocation for user ID {}: {} credits available", - userId, - newAllocation); - } - - /** - * Consumes credits with explicit waterfall logic for Pro billing model. Implements the - * following priority order: - * - *

      - *
    1. Cycle Credits: Try consuming from cycle credit allocation (100/month for Pro, - * 25/month for Free) - *
    2. Bought Credits: Try consuming from purchased credits - *
    3. Metered Billing: If user.has_metered_billing_enabled, report to Stripe and allow - *
    4. Reject: No available credit source (Pro users without metered billing get - * helpful message about enabling overage billing) - *
    - * - * @param user User consuming credits - * @param creditAmount Amount to consume - * @param isApiRequest True if API key request, false if UI request (both consume credits for - * Pro users) - * @return CreditConsumptionResult with source and success status - */ - public CreditConsumptionResult consumeCreditWithWaterfall( - User user, int creditAmount, boolean isApiRequest) { - - log.debug( - "[WATERFALL] Starting credit consumption for user {} - Amount: {}, API request: {}", - user.getUsername(), - creditAmount, - isApiRequest); - - // Internal API users (e.g., CUSTOM_API_USER) get unlimited credits, no Supabase ID needed - if (user.getRolesAsString().contains("STIRLING-PDF-BACKEND-API-USER")) { - log.debug( - "[WATERFALL] Internal API user {} - unlimited credits, skipping consumption", - user.getUsername()); - creditsConsumedCounter.increment(creditAmount); - return CreditConsumptionResult.success("INTERNAL_API_UNLIMITED"); - } - - UUID supabaseId = user.getSupabaseId(); - if (supabaseId == null) { - log.error("[WATERFALL] User {} has no Supabase ID", user.getUsername()); - return CreditConsumptionResult.failure("User has no Supabase ID"); - } - - // STEP 2: Try cycle free credits - Boolean hasCycle = userCreditRepository.hasCycleCredits(supabaseId, creditAmount); - if (Boolean.TRUE.equals(hasCycle)) { - int rowsUpdated = userCreditRepository.consumeCycleCredits(supabaseId, creditAmount); - if (rowsUpdated == 1) { - creditsConsumedCounter.increment(creditAmount); - log.info( - "[WATERFALL] Consumed {} cycle credits for user: {}", - creditAmount, - user.getUsername()); - return CreditConsumptionResult.success("CYCLE_CREDITS"); - } - } - - // STEP 3: Try purchased credits - Boolean hasBought = userCreditRepository.hasBoughtCredits(supabaseId, creditAmount); - if (Boolean.TRUE.equals(hasBought)) { - int rowsUpdated = userCreditRepository.consumeBoughtCredits(supabaseId, creditAmount); - if (rowsUpdated == 1) { - creditsConsumedCounter.increment(creditAmount); - log.info( - "[WATERFALL] Consumed {} bought credits for user: {}", - creditAmount, - user.getUsername()); - return CreditConsumptionResult.success("BOUGHT_CREDITS"); - } - } - - // STEP 4: Try metered billing (check flag, not role) - if (saasUserExtensionService.isMeteredBillingEnabled(user)) { - log.info( - "[WATERFALL] User {} has metered billing enabled; scheduling {} credits for" - + " Stripe report (after commit)", - user.getUsername(), - creditAmount); - - String operationId = MDC.get("requestId"); - String idempotencyKey = - stripeUsageReportingService.generateIdempotencyKey( - supabaseId.toString(), creditAmount, operationId); - - scheduleStripeReportAfterCommit( - supabaseId.toString(), - creditAmount, - idempotencyKey, - creditAmount, - /* freeCreditsUsed= */ 0); - return CreditConsumptionResult.success("METERED_SUBSCRIPTION"); - } else if (user.getRolesAsString().contains("ROLE_PRO_USER")) { - // Pro user without metered billing enabled; reject with helpful message - log.warn( - "[WATERFALL] Pro user {} has exhausted credits but metered billing not enabled. Rejecting request.", - user.getUsername()); - log.info( - "[WATERFALL] User should set up overage billing via UI to enable uninterrupted service."); - - creditConsumptionFailuresCounter.increment(); - return CreditConsumptionResult.failure( - "Credits exhausted. Please enable overage billing in settings for uninterrupted service."); - } - - // STEP 5: Reject; no available credit source - log.warn( - "[WATERFALL] No credit source available for user: {} (needed: {} credits)", - user.getUsername(), - creditAmount); - creditConsumptionFailuresCounter.increment(); - return CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"); - } - - public static class CreditSummary { - public final int cycleCreditsRemaining; - public final int cycleCreditsAllocated; - public final int boughtCreditsRemaining; - public final int totalBoughtCredits; - public final int totalAvailableCredits; - public final LocalDateTime cycleResetDate; - public final LocalDateTime lastApiUsage; - public final boolean unlimited; - - public CreditSummary() { - this(0, 0, 0, 0, 0, null, null, false); - } - - public CreditSummary( - int cycleCreditsRemaining, - int cycleCreditsAllocated, - int boughtCreditsRemaining, - int totalBoughtCredits, - int totalAvailableCredits, - LocalDateTime cycleResetDate, - LocalDateTime lastApiUsage, - boolean unlimited) { - this.cycleCreditsRemaining = cycleCreditsRemaining; - this.cycleCreditsAllocated = cycleCreditsAllocated; - this.boughtCreditsRemaining = boughtCreditsRemaining; - this.totalBoughtCredits = totalBoughtCredits; - this.totalAvailableCredits = totalAvailableCredits; - this.cycleResetDate = cycleResetDate; - this.lastApiUsage = lastApiUsage; - this.unlimited = unlimited; - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 43637c2d0..7c77c6a0e 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -2,7 +2,6 @@ package stirling.software.saas.service; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import java.util.UUID; import org.springframework.context.annotation.Profile; @@ -21,16 +20,12 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; -import stirling.software.saas.config.CreditsProperties; import stirling.software.saas.config.SupabaseConfigurationProperties; -import stirling.software.saas.model.TeamCredit; import stirling.software.saas.model.TeamInvitation; import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.SaasTeamExtensionsRepository; -import stirling.software.saas.repository.TeamCreditRepository; import stirling.software.saas.repository.TeamInvitationRepository; import stirling.software.saas.repository.TeamMembershipRepository; -import stirling.software.saas.repository.UserCreditRepository; /** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */ @Service @@ -43,11 +38,7 @@ public class SaasTeamService { private final TeamMembershipRepository membershipRepository; private final TeamInvitationRepository invitationRepository; private final UserRepository userRepository; - private final UserCreditRepository userCreditRepository; private final BillingSubscriptionRepository billingSubscriptionRepository; - private final TeamCreditService teamCreditService; - private final TeamCreditRepository teamCreditRepository; - private final CreditsProperties creditsProperties; private final RestTemplate restTemplate; private final RateLimitService rateLimitService; private final SupabaseConfigurationProperties supabaseConfig; @@ -100,9 +91,6 @@ public class SaasTeamService { user.setTeam(savedTeam); userRepository.save(user); - // Initialize team credits - teamCreditService.initializeTeamCredits(savedTeam, user); - // Clean up old Default/Internal team membership if (oldTeam != null && (DEFAULT_TEAM_NAME.equals(oldTeam.getName()) @@ -783,64 +771,6 @@ public class SaasTeamService { teamRepository.save(team); - Optional creditOpt = teamCreditRepository.findByTeamId(teamId); - - int fixedAllocation = - creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500); - - if (creditOpt.isPresent()) { - TeamCredit credit = creditOpt.get(); - - int oldAllocation = - credit.getCycleCreditsAllocated() != null - ? credit.getCycleCreditsAllocated() - : 0; - - if (oldAllocation != fixedAllocation) { - int currentRemaining = - credit.getCycleCreditsRemaining() != null - ? credit.getCycleCreditsRemaining() - : 0; - int allocationDifference = fixedAllocation - oldAllocation; - - credit.setCycleCreditsAllocated(fixedAllocation); - - int newRemaining = Math.max(0, currentRemaining + allocationDifference); - credit.setCycleCreditsRemaining(newRemaining); - - teamCreditRepository.save(credit); - - log.info( - "Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}", - teamId, - oldAllocation, - fixedAllocation, - currentRemaining, - newRemaining); - } else { - log.debug( - "Team {} already has fixed allocation of {} credits, no update needed", - teamId, - fixedAllocation); - } - } else { - log.warn("Team {} missing credit record; creating with fixed allocation", teamId); - TeamCredit credit = new TeamCredit(team); - - credit.setCycleCreditsAllocated(fixedAllocation); - credit.setCycleCreditsRemaining(fixedAllocation); - credit.setBoughtCreditsRemaining(0); - credit.setTotalBoughtCredits(0); - credit.setTotalApiCallsMade(0L); - credit.setLastCycleResetAt(LocalDateTime.now()); - teamCreditRepository.save(credit); - - log.info( - "Created team_credits record for team {} with {} fixed credits (unlimited seats model)", - teamId, - fixedAllocation); - } - log.info( "Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}", teamId, diff --git a/app/saas/src/main/java/stirling/software/saas/service/TeamCreditService.java b/app/saas/src/main/java/stirling/software/saas/service/TeamCreditService.java deleted file mode 100644 index 5d1ca0048..000000000 --- a/app/saas/src/main/java/stirling/software/saas/service/TeamCreditService.java +++ /dev/null @@ -1,279 +0,0 @@ -package stirling.software.saas.service; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.billing.service.StripeUsageReportingService; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamCreditRepository; -import stirling.software.saas.repository.TeamMembershipRepository; - -/** - * Service for managing team credit pools. Handles credit initialization, consumption, and cycle - * resets for teams. - */ -@Service -@Profile("saas") -@RequiredArgsConstructor -@Slf4j -public class TeamCreditService { - - private final TeamCreditRepository teamCreditRepository; - private final TeamMembershipRepository membershipRepository; - private final CreditsProperties creditsProperties; - private final StripeUsageReportingService stripeUsageReportingService; - private final SaasUserExtensionService saasUserExtensionService; - - /** Initialise a fixed PRO credit allocation for a new team. */ - @Transactional - public TeamCredit initializeTeamCredits(Team team, User primaryUser) { - Optional existing = teamCreditRepository.findByTeamId(team.getId()); - if (existing.isPresent()) { - log.debug("Team credits already exist for team {}", team.getId()); - return existing.get(); - } - - TeamCredit credits = new TeamCredit(team); - - // Fixed PRO allocation; seat-independent. - int proAllocation = - creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500); - int totalCycleAllocation = proAllocation; - - credits.setCycleCreditsAllocated(totalCycleAllocation); - credits.setCycleCreditsRemaining(totalCycleAllocation); - credits.setLastCycleResetAt(LocalDateTime.now()); - - TeamCredit saved = teamCreditRepository.save(credits); - log.info( - "Initialized team credits for team {} with {} cycle credits (fixed PRO amount)", - team.getId(), - totalCycleAllocation); - return saved; - } - - /** - * Check if team has credits available - * - * @param teamId the team ID - * @return true if team has credits available - */ - public boolean hasCreditsAvailable(Long teamId) { - return teamCreditRepository - .findByTeamId(teamId) - .map(TeamCredit::hasCreditsAvailable) - .orElse(false); - } - - /** - * Atomically consume credits from team pool - * - * @param teamId the team ID - * @param amount number of credits to consume - * @return true if credits were consumed, false if insufficient credits or version conflict - */ - @Transactional - public boolean consumeCredit(Long teamId, int amount) { - int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount); - if (rowsUpdated == 0) { - log.warn( - "Failed to consume {} credits for team {} (insufficient credits or version conflict)", - amount, - teamId); - return false; - } - log.debug("Consumed {} credits for team {}", amount, teamId); - return true; - } - - /** - * Get team credit summary for a user's team. - * - * @param user the user - * @return Optional of TeamCredit for the user's team - */ - public Optional getCreditSummaryForUser(User user) { - if (user.getTeam() == null) { - log.warn("User {} has no team assigned", user.getId()); - return Optional.empty(); - } - - Long teamId = user.getTeam().getId(); - log.debug("Using user's team {} for credit summary", teamId); - return teamCreditRepository.findByTeamId(teamId); - } - - /** - * Get team credits by team ID - * - * @param teamId the team ID - * @return Optional of TeamCredit - */ - public Optional getTeamCredits(Long teamId) { - return teamCreditRepository.findByTeamId(teamId); - } - - /** - * Add bought credits to team pool - * - * @param teamId the team ID - * @param credits number of credits to add - */ - @Transactional - public void addBoughtCredits(Long teamId, int credits) { - TeamCredit teamCredit = - teamCreditRepository - .findByTeamId(teamId) - .orElseThrow(() -> new IllegalArgumentException("Team credits not found")); - - teamCredit.addBoughtCredits(credits); - teamCreditRepository.save(teamCredit); - log.info("Added {} bought credits to team {}", credits, teamId); - } - - /** - * Reset cycle credits for team - * - * @param teamId the team ID - * @param cycleAllocation new cycle allocation - * @param resetTime reset timestamp - */ - @Transactional - public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) { - TeamCredit teamCredit = - teamCreditRepository - .findByTeamId(teamId) - .orElseThrow(() -> new IllegalArgumentException("Team credits not found")); - - teamCredit.resetCycleCredits(cycleAllocation, resetTime); - teamCreditRepository.save(teamCredit); - log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation); - } - - /** - * Consume from the team credit pool; falls through to the team leader's metered Stripe billing - * when the pool is exhausted. - */ - @Transactional - public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) { - log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount); - - // Step 1: Try consuming from team credit pool - int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount); - if (rowsUpdated == 1) { - log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId); - return CreditConsumptionResult.success("TEAM_CREDITS"); - } - - log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId); - - // Step 2: Get team leader - Optional leaderOpt = getTeamLeader(teamId); - if (leaderOpt.isEmpty()) { - log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId); - return CreditConsumptionResult.failure("NO_TEAM_LEADER"); - } - - User teamLeader = leaderOpt.get(); - - // Step 3: Check if team leader has metered billing enabled - if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) { - log.warn( - "[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled", - teamId, - teamLeader.getUsername()); - return CreditConsumptionResult.failure( - "TEAM_CREDITS_EXHAUSTED_NO_OVERAGE", - "Team credits exhausted. Team leader must enable overage billing for" - + " uninterrupted service."); - } - - // Step 4: Report overage to Stripe via team leader's metered billing - String leaderSupabaseId = - teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null; - - if (leaderSupabaseId == null) { - log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername()); - return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID"); - } - - try { - String operationId = org.slf4j.MDC.get("requestId"); - if (operationId == null || operationId.isBlank()) { - operationId = java.util.UUID.randomUUID().toString(); - } - String idempotencyKey = - stripeUsageReportingService.generateIdempotencyKey( - leaderSupabaseId, amount, operationId); - - log.info( - "[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}", - amount, - teamId, - teamLeader.getUsername()); - - boolean reported = - stripeUsageReportingService.reportUsageToStripe( - leaderSupabaseId, amount, idempotencyKey); - - if (reported) { - log.info( - "[TEAM-CREDIT] Successfully reported {} overage credits for team {} via" - + " leader {}", - amount, - teamId, - teamLeader.getUsername()); - return CreditConsumptionResult.success("TEAM_LEADER_METERED"); - } else { - log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId); - return CreditConsumptionResult.failure( - "STRIPE_REPORTING_FAILED", - "Unable to report usage to Stripe. Please try again."); - } - } catch (Exception e) { - log.error( - "[TEAM-CREDIT] Exception reporting overage for team {}: {}", - teamId, - e.getMessage(), - e); - return CreditConsumptionResult.failure( - "STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage()); - } - } - - /** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */ - private Optional getTeamLeader(Long teamId) { - List leaders = - membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER); - - if (leaders.isEmpty()) { - log.warn("Team {} has no leaders", teamId); - return Optional.empty(); - } - - // Return first leader (typically only one leader per team) - TeamMembership leader = leaders.get(0); - User leaderUser = leader.getUser(); - log.debug( - "Found team {} leader: {} (user ID: {})", - teamId, - leaderUser.getUsername(), - leaderUser.getId()); - - return Optional.of(leaderUser); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/service/UserRoleService.java b/app/saas/src/main/java/stirling/software/saas/service/UserRoleService.java index 7a6452cdb..e54758221 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/UserRoleService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/UserRoleService.java @@ -12,10 +12,9 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; import stirling.software.saas.util.LogRedactionUtils; -/** Changes user roles and refreshes their credit allocation. */ +/** Changes user roles (and the matching authority grant/revoke). */ @Service @Profile("saas") @RequiredArgsConstructor @@ -24,8 +23,6 @@ public class UserRoleService { private final UserRepository userRepository; private final AuthorityRepository authorityRepository; - private final CreditService creditService; - private final CreditsProperties creditsProperties; /** * Change a user's role @@ -58,7 +55,7 @@ public class UserRoleService { /** * Downgrade a user to FREE tier (ROLE_USER) * - *

    Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier. + *

    Revokes ROLE_PRO_USER by changing the role/authority from PRO_USER to USER. * * @param user the user to downgrade */ @@ -70,24 +67,15 @@ public class UserRoleService { changeRole(user, Role.USER.getRoleId()); - // Reset credits to FREE tier allocation - int freeAllocation = - creditsProperties - .getCycle() - .getAllocations() - .getOrDefault(Role.USER.getRoleId(), 25); - creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation); - log.info( - "Successfully downgraded user {} to FREE with {} cycle credits", - LogRedactionUtils.redactEmail(user.getUsername()), - freeAllocation); + "Successfully downgraded user {} to FREE", + LogRedactionUtils.redactEmail(user.getUsername())); } /** * Upgrade a user to PRO tier (ROLE_PRO_USER) * - *

    Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier. + *

    Grants ROLE_PRO_USER by changing the role/authority from USER to PRO_USER. * * @param user the user to upgrade */ @@ -98,30 +86,8 @@ public class UserRoleService { changeRole(user, Role.PRO_USER.getRoleId()); - // Reset credits to PRO tier allocation - int proAllocation = - creditsProperties - .getCycle() - .getAllocations() - .getOrDefault(Role.PRO_USER.getRoleId(), 100); - creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation); - log.info( - "Successfully upgraded user {} to PRO with {} cycle credits", - LogRedactionUtils.redactEmail(user.getUsername()), - proAllocation); - } - - /** - * Get credit allocation for a specific role - * - * @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER") - * @return the cycle credit allocation for that role - */ - public int getCreditAllocationForRole(String roleId) { - return creditsProperties - .getCycle() - .getAllocations() - .getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100); + "Successfully upgraded user {} to PRO", + LogRedactionUtils.redactEmail(user.getUsername())); } } diff --git a/app/saas/src/main/java/stirling/software/saas/util/CreditHeaderUtils.java b/app/saas/src/main/java/stirling/software/saas/util/CreditHeaderUtils.java deleted file mode 100644 index 9e432b068..000000000 --- a/app/saas/src/main/java/stirling/software/saas/util/CreditHeaderUtils.java +++ /dev/null @@ -1,97 +0,0 @@ -package stirling.software.saas.util; - -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; - -/** - * Resolves the user's remaining credit balance. Uses the team pool for non-personal team members, - * otherwise the user's individual credits (looked up by Supabase ID or API key). - */ -@Component -@Profile("saas") -@RequiredArgsConstructor -@Slf4j -public class CreditHeaderUtils { - - private final SaasTeamExtensionService saasTeamExtensionService; - - /** - * Get the remaining credits for a user, checking team credits first (non-personal teams only). - * - * @param user The user whose credits to check - * @param creditService The credit service to fetch user credits - * @param teamCreditService The team credit service to fetch team credits - * @return The remaining credit balance, or -1 if credits cannot be determined - */ - public int getRemainingCredits( - User user, CreditService creditService, TeamCreditService teamCreditService) { - try { - // Limited-API users always read personal credits. - boolean isLimitedApiUser = - user.getAuthorities().stream() - .anyMatch( - authority -> - "ROLE_LIMITED_API_USER".equals(authority.getAuthority()) - || "ROLE_EXTRA_LIMITED_API_USER" - .equals(authority.getAuthority())); - - Long targetTeamId = null; - if (!isLimitedApiUser - && user.getTeam() != null - && !saasTeamExtensionService.isPersonal(user.getTeam())) { - targetTeamId = user.getTeam().getId(); - } - - if (targetTeamId != null) { - return teamCreditService - .getTeamCredits(targetTeamId) - .map(TeamCredit::getTotalAvailableCredits) - .orElse(-1); - } else { - log.debug( - "[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}", - user.getSupabaseId(), - user.getApiKey() != null ? "present" : "null", - user.getUsername()); - - Optional credits; - if (user.getSupabaseId() != null) { - credits = - creditService.getUserCreditsBySupabaseId( - user.getSupabaseId().toString()); - log.debug( - "[CREDIT-HEADER] Looked up by SupabaseId - Found: {}", - credits.isPresent()); - } else if (user.getApiKey() != null) { - credits = creditService.getUserCreditsByApiKey(user.getApiKey()); - log.debug( - "[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent()); - } else { - log.warn( - "[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}", - user.getUsername()); - return -1; - } - - int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1); - log.debug("[CREDIT-HEADER] Returning credits: {}", remaining); - return remaining; - } - } catch (Exception e) { - log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e); - return -1; - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java index bab91affc..061486970 100644 --- a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiCreateControllerTest.java @@ -64,17 +64,13 @@ import stirling.software.saas.payg.charge.JobChargeService; import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ProcessType; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.CreditHeaderUtils; /** * Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's * handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}. * - *

    The controller reads {@code SecurityContextHolder} in the charge + credit-header paths, so - * each relevant test seeds an authentication and {@link #clearSecurityContext()} resets it - * afterwards. + *

    The controller reads {@code SecurityContextHolder} in the charge path, so each relevant test + * seeds an authentication and {@link #clearSecurityContext()} resets it afterwards. */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -82,10 +78,7 @@ class AiCreateControllerTest { @Mock private AiCreateSessionService sessionService; @Mock private AiCreateProxyService proxyService; - @Mock private CreditService creditService; - @Mock private TeamCreditService teamCreditService; @Mock private UserRepository userRepository; - @Mock private CreditHeaderUtils creditHeaderUtils; @Mock private JobChargeService jobChargeService; private AiCreateController controller; @@ -94,13 +87,7 @@ class AiCreateControllerTest { void setUp() { controller = new AiCreateController( - sessionService, - proxyService, - creditService, - teamCreditService, - userRepository, - creditHeaderUtils, - jobChargeService); + sessionService, proxyService, userRepository, jobChargeService); } @AfterEach @@ -729,8 +716,6 @@ class AiCreateControllerTest { // Ownership guard runs before the proxy. verify(sessionService).getSessionForCurrentUser("sess-1"); assertThat(drain(resp.getBody())).isEqualTo("section data"); - // No credit header on the non-AI-triggering endpoint. - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); } @Test @@ -761,7 +746,7 @@ class AiCreateControllerTest { } // ---------------------------------------------------------------------------------------------- - // stream (proxy, accept event-stream + credit header) + // stream (proxy, accept event-stream) // ---------------------------------------------------------------------------------------------- @Nested @@ -769,13 +754,9 @@ class AiCreateControllerTest { class Stream { @Test - @DisplayName("proxies GET as event-stream and adds X-Credits-Remaining for authed user") - void stream_addsCreditHeader() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(42); - + @DisplayName( + "checks ownership, proxies GET as event-stream, defaults Content-Type, and streams") + void stream_proxiesEventStreamAndStreams() throws Exception { HttpServletRequest req = mock(HttpServletRequest.class); HttpResponse upstream = upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of())); @@ -789,80 +770,11 @@ class AiCreateControllerTest { // No upstream Content-Type → defaulted to text/event-stream. assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)) .isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42"); + // Ownership guard runs before the proxy. verify(sessionService).getSessionForCurrentUser("sess-1"); assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n"); } - @Test - @DisplayName("negative remaining credits suppresses the credit header") - void stream_negativeCredits_omitsHeader() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(-1); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of())); - when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream); - - ResponseEntity resp = controller.stream("sess-1", req); - - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - } - - @Test - @DisplayName("zero remaining credits still emits the header (>= 0 boundary)") - void stream_zeroCredits_emitsHeader() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(0); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of())); - when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream); - - ResponseEntity resp = controller.stream("sess-1", req); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0"); - } - - @Test - @DisplayName("unauthenticated context: stream still proxies, no credit header, no NPE") - void stream_noAuth_omitsCreditHeader() throws Exception { - SecurityContextHolder.clearContext(); - HttpServletRequest req = mock(HttpServletRequest.class); - HttpResponse upstream = upstreamResponse(200, "x", httpHeaders(Map.of())); - when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream); - - ResponseEntity resp = controller.stream("sess-1", req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - verifyNoInteractions(creditHeaderUtils); - } - - @Test - @DisplayName("credit-header lookup blowing up does not break the stream response") - void stream_creditLookupThrows_stillStreams() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(any(), any(), any())) - .thenThrow(new RuntimeException("boom")); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpResponse upstream = - upstreamResponse(200, "payload", httpHeaders(Map.of())); - when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream); - - ResponseEntity resp = controller.stream("sess-1", req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - assertThat(drain(resp.getBody())).isEqualTo("payload"); - } - @Test @DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins") void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception { @@ -904,7 +816,7 @@ class AiCreateControllerTest { } @Test - @DisplayName("ownership failure short-circuits before proxy/credit work") + @DisplayName("ownership failure short-circuits before proxying") void stream_ownershipFailure_doesNotProxy() throws Exception { HttpServletRequest req = mock(HttpServletRequest.class); when(sessionService.getSessionForCurrentUser("sess-1")) @@ -913,7 +825,6 @@ class AiCreateControllerTest { assertThatThrownBy(() -> controller.stream("sess-1", req)) .isInstanceOf(ResponseStatusException.class); verify(proxyService, never()).forward(any(), any(), any(), anyBoolean()); - verifyNoInteractions(creditHeaderUtils); } } diff --git a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java index 51b098df1..021c04351 100644 --- a/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/ai/controller/AiProxyControllerTest.java @@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; @@ -17,7 +16,6 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -30,70 +28,41 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import jakarta.servlet.http.HttpServletRequest; -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; import stirling.software.saas.ai.service.AiProxyService; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.CreditHeaderUtils; /** * Pure unit tests for {@link AiProxyController}. Every collaborator is mocked; each handler is * invoked directly and asserted via {@link ResponseEntity} / {@code verify}. * *

    All endpoints funnel through one private {@code proxy(method, path, request, - * acceptEventStream, includeCreditsHeader)} helper, so the suite has two halves: + * acceptEventStream)} helper, so the suite has two halves: * *

      *
    1. per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a * given handler forwards (the path-mapping surface), and *
    2. behavioural tests around the single shared {@code proxy} body: header copy, status - * resolution, the 503 error fallback, and the credit-header path keyed off {@code - * includeCreditsHeader}. + * resolution, and the 503 error fallback. *
    - * - *

    The credit-header branch reads {@code SecurityContextHolder}, so the relevant tests seed an - * authentication and {@link #clearSecurityContext()} resets it afterwards. */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class AiProxyControllerTest { @Mock private AiProxyService aiProxyService; - @Mock private CreditService creditService; - @Mock private TeamCreditService teamCreditService; - @Mock private UserRepository userRepository; - @Mock private CreditHeaderUtils creditHeaderUtils; private AiProxyController controller; @org.junit.jupiter.api.BeforeEach void setUp() { - controller = - new AiProxyController( - aiProxyService, - creditService, - teamCreditService, - userRepository, - creditHeaderUtils); - } - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); + controller = new AiProxyController(aiProxyService); } // ---------------------------------------------------------------------------------------------- // Endpoint path/method mapping — each handler pins the exact upstream contract it forwards. - // None of these endpoints request the credit header except chat/* and edit message; see the - // dedicated credit-header section for those. // ---------------------------------------------------------------------------------------------- @Nested @@ -101,7 +70,7 @@ class AiProxyControllerTest { class EndpointMapping { @Test - @DisplayName("generateSection POSTs to /api/generate_section, non-stream, no credit header") + @DisplayName("generateSection POSTs to /api/generate_section, non-stream") void generateSection() throws Exception { HttpServletRequest req = req(); stubForward("POST", "/api/generate_section", req, false, ok("body")); @@ -110,7 +79,6 @@ class AiProxyControllerTest { assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); verify(aiProxyService).forward("POST", "/api/generate_section", req, false); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); } @Test @@ -135,6 +103,39 @@ class AiProxyControllerTest { verify(aiProxyService).forward("POST", "/api/intent/check", req, false); } + @Test + @DisplayName("chatRoute POSTs to /api/chat/route") + void chatRoute() throws Exception { + HttpServletRequest req = req(); + stubForward("POST", "/api/chat/route", req, false, ok("body")); + + controller.chatRoute(req); + + verify(aiProxyService).forward("POST", "/api/chat/route", req, false); + } + + @Test + @DisplayName("createSmartFolder POSTs to /api/chat/create-smart-folder") + void createSmartFolder() throws Exception { + HttpServletRequest req = req(); + stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("body")); + + controller.createSmartFolder(req); + + verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false); + } + + @Test + @DisplayName("chatInfo POSTs to /api/chat/info") + void chatInfo() throws Exception { + HttpServletRequest req = req(); + stubForward("POST", "/api/chat/info", req, false, ok("body")); + + controller.chatInfo(req); + + verify(aiProxyService).forward("POST", "/api/chat/info", req, false); + } + @Test @DisplayName("pdfAnswer POSTs to /api/pdf/answer") void pdfAnswer() throws Exception { @@ -212,6 +213,18 @@ class AiProxyControllerTest { verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false); } + @Test + @DisplayName("editSessionMessage POSTs /api/edit/sessions/{id}/messages") + void editSessionMessage() throws Exception { + HttpServletRequest req = req(); + stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("body")); + + controller.editSessionMessage("sess-9", req); + + verify(aiProxyService) + .forward("POST", "/api/edit/sessions/sess-9/messages", req, false); + } + @Test @DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments") void editSessionAttachment() throws Exception { @@ -228,7 +241,7 @@ class AiProxyControllerTest { @DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream") void runEditSession() throws Exception { HttpServletRequest req = req(); - // acceptEventStream == true here (and no credit header). + // acceptEventStream == true here. stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n")); controller.runEditSession("sess-9", req); @@ -505,234 +518,6 @@ class AiProxyControllerTest { assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(drain(resp.getBody())).contains("AI backend unavailable"); } - - @Test - @DisplayName("a credit-bearing endpoint failing in forward never reaches the credit path") - void creditEndpointFailure_skipsCreditWork() throws Exception { - HttpServletRequest req = req(); - authenticateWeb(userWithTeam(7L, 100L)); - when(aiProxyService.forward(any(), any(), any(), anyBoolean())) - .thenThrow(new java.io.IOException("down")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); - // The credit header is only added after a successful forward. - verifyNoInteractions(creditHeaderUtils); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------------- - // Credit headers — only the chat/* and edit-message endpoints set includeCreditsHeader = true. - // ---------------------------------------------------------------------------------------------- - - @Nested - @DisplayName( - "credit-header endpoints (chatRoute / createSmartFolder / chatInfo / editSessionMessage)") - class CreditHeaders { - - @Test - @DisplayName( - "chatRoute POSTs /api/chat/route and adds X-Credits-Remaining for an authed user") - void chatRoute_addsCreditHeaders() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(42); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL"); - verify(aiProxyService).forward("POST", "/api/chat/route", req, false); - } - - @Test - @DisplayName("createSmartFolder POSTs /api/chat/create-smart-folder with the credit header") - void createSmartFolder_addsCreditHeaders() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(5); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("folder")); - - ResponseEntity resp = controller.createSmartFolder(req); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("5"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL"); - verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false); - } - - @Test - @DisplayName("chatInfo POSTs /api/chat/info with the credit header") - void chatInfo_addsCreditHeaders() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(3); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/info", req, false, ok("info")); - - ResponseEntity resp = controller.chatInfo(req); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("3"); - verify(aiProxyService).forward("POST", "/api/chat/info", req, false); - } - - @Test - @DisplayName( - "editSessionMessage POSTs /api/edit/sessions/{id}/messages with the credit header") - void editSessionMessage_addsCreditHeaders() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(99); - HttpServletRequest req = req(); - stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("msg")); - - ResponseEntity resp = - controller.editSessionMessage("sess-9", req); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("99"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL"); - verify(aiProxyService) - .forward("POST", "/api/edit/sessions/sess-9/messages", req, false); - } - - @Test - @DisplayName("zero remaining credits still emits the header (>= 0 boundary)") - void zeroCredits_emitsHeader() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(0); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL"); - } - - @Test - @DisplayName( - "negative remaining credits omits X-Credits-Remaining but still sets the source") - void negativeCredits_omitsRemainingButKeepsSource() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService)) - .thenReturn(-1); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - // X-Credit-Source is set unconditionally once we reach the credit path. - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL"); - } - - @Test - @DisplayName( - "no authentication: credit work is skipped, no credit headers, response still streams") - void noAuth_skipsCreditHeaders() throws Exception { - SecurityContextHolder.clearContext(); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); - verifyNoInteractions(creditHeaderUtils); - assertThat(drain(resp.getBody())).isEqualTo("reply"); - } - - @Test - @DisplayName("an unauthenticated token (isAuthenticated()==false) is treated as no-auth") - void unauthenticatedToken_skipsCreditHeaders() throws Exception { - // 2-arg ctor → isAuthenticated() == false, so the credit branch short-circuits. - UsernamePasswordAuthenticationToken token = - new UsernamePasswordAuthenticationToken("alice", "pw"); - SecurityContextHolder.getContext().setAuthentication(token); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); - verifyNoInteractions(creditHeaderUtils); - } - - @Test - @DisplayName("credit-header work blowing up is swallowed; the response still streams") - void creditLookupThrows_stillStreams() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(any(), any(), any())) - .thenThrow(new RuntimeException("boom")); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("payload")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - // The catch in addCreditHeaders runs before X-Credit-Source is set, so neither lands. - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); - assertThat(drain(resp.getBody())).isEqualTo("payload"); - } - - @Test - @DisplayName("resolving the current user failing is swallowed; the response still streams") - void getCurrentUserThrows_stillStreams() throws Exception { - // EnhancedJwtAuthenticationToken is the only auth type with a getCurrentUser DB lookup, - // but a plain authed token whose principal is a User short-circuits there. To exercise - // the swallow we make the downstream credit lookup throw (covered above); here we - // assert - // that a non-User principal that can't be resolved doesn't break the stream. - UsernamePasswordAuthenticationToken token = - new UsernamePasswordAuthenticationToken("alice", "pw", List.of()); - // principal is the String "alice"; getCurrentUser will hit - // userRepository.findByUsername. - when(userRepository.findByUsername("alice")).thenReturn(java.util.Optional.empty()); - SecurityContextHolder.getContext().setAuthentication(token); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("payload")); - - ResponseEntity resp = controller.chatRoute(req); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse(); - assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse(); - assertThat(drain(resp.getBody())).isEqualTo("payload"); - verifyNoInteractions(creditHeaderUtils); - } - - @Test - @DisplayName( - "an authed User principal is passed straight to the credit utils (no repo lookup)") - void authedUserPrincipal_passedToCreditUtils() throws Exception { - User user = userWithTeam(7L, 100L); - authenticateWeb(user); - when(creditHeaderUtils.getRemainingCredits(any(), any(), any())).thenReturn(11); - HttpServletRequest req = req(); - stubForward("POST", "/api/chat/route", req, false, ok("reply")); - - controller.chatRoute(req); - - // The principal User is forwarded verbatim alongside both credit services. - verify(creditHeaderUtils).getRemainingCredits(user, creditService, teamCreditService); - verifyNoInteractions(userRepository); - } } // ---------------------------------------------------------------------------------------------- @@ -759,24 +544,6 @@ class AiProxyControllerTest { return upstreamResponse(200, body, httpHeaders(Map.of())); } - private static User userWithTeam(long userId, long teamId) { - User user = new User(); - user.setId(userId); - Team team = new Team(); - team.setId(teamId); - user.setTeam(team); - return user; - } - - /** - * Authenticated WEB principal: 3-arg ctor so isAuthenticated()==true, principal is the User. - */ - private static void authenticateWeb(User user) { - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(user, null, List.of()); - SecurityContextHolder.getContext().setAuthentication(auth); - } - private static java.net.http.HttpHeaders httpHeaders(Map single) { Map> multi = new java.util.HashMap<>(); single.forEach((k, v) -> multi.put(k, List.of(v))); diff --git a/app/saas/src/test/java/stirling/software/saas/controller/CreditControllerApiKeyTest.java b/app/saas/src/test/java/stirling/software/saas/controller/CreditControllerApiKeyTest.java deleted file mode 100644 index 89ec2473d..000000000 --- a/app/saas/src/test/java/stirling/software/saas/controller/CreditControllerApiKeyTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package stirling.software.saas.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; - -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.CreditService.CreditSummary; - -/** - * Regression coverage for finding #15: API-key users used to always see empty credits because the - * controller blindly passed the API key string through to {@code getCreditSummaryBySupabaseId}, - * which then blew up on {@code UUID.fromString}. The new code reads the User from the principal and - * prefers the linked Supabase ID, falling back to API-key-keyed credits. - */ -@ExtendWith(MockitoExtension.class) -class CreditControllerApiKeyTest { - - @Mock private CreditService creditService; - - @Test - void apiKeyUserWithSupabaseIdGetsResolvedToSupabaseLookup() { - UUID supabaseId = UUID.randomUUID(); - User u = new User(); - u.setSupabaseId(supabaseId); - - CreditSummary expected = creditSummary(42, 100); - when(creditService.getCreditSummaryBySupabaseId(supabaseId.toString())) - .thenReturn(expected); - - CreditController controller = new CreditController(creditService); - ApiKeyAuthenticationToken token = - new ApiKeyAuthenticationToken(u, "the-api-key", java.util.List.of()); - - ResponseEntity resp = controller.getUserCredits(token); - - assertThat(resp.getBody()).isSameAs(expected); - verify(creditService).getCreditSummaryBySupabaseId(supabaseId.toString()); - } - - @Test - void apiKeyUserWithoutSupabaseIdFallsBackToApiKeyLookup() { - User u = new User(); - // No supabaseId set — covers self-hosted / OSS-style API-only users. - CreditSummary expected = creditSummary(7, 25); - when(creditService.getCreditSummaryByApiKey("apikey-no-supabase")).thenReturn(expected); - - CreditController controller = new CreditController(creditService); - ApiKeyAuthenticationToken token = - new ApiKeyAuthenticationToken(u, "apikey-no-supabase", java.util.List.of()); - - ResponseEntity resp = controller.getUserCredits(token); - - assertThat(resp.getBody()).isSameAs(expected); - verify(creditService).getCreditSummaryByApiKey(eq("apikey-no-supabase")); - } - - @Test - void apiKeyTokenWithoutUserPrincipalFallsBackToApiKeyLookup() { - // Edge: token wasn't constructed with a User principal. Should still attempt API-key - // lookup rather than throw. - CreditSummary expected = creditSummary(0, 0); - when(creditService.getCreditSummaryByApiKey("orphan-key")).thenReturn(expected); - - CreditController controller = new CreditController(creditService); - ApiKeyAuthenticationToken token = - new ApiKeyAuthenticationToken("not-a-user", "orphan-key", java.util.List.of()); - - ResponseEntity resp = controller.getUserCredits(token); - - assertThat(resp.getBody()).isNotNull(); - verify(creditService).getCreditSummaryByApiKey("orphan-key"); - } - - private static CreditSummary creditSummary(int remaining, int allocated) { - return new CreditSummary(remaining, allocated, 0, 0, remaining, null, null, false); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java deleted file mode 100644 index 6d54444b8..000000000 --- a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditErrorAdviceTest.java +++ /dev/null @@ -1,725 +0,0 @@ -package stirling.software.saas.interceptor; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.ErrorTrackingService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.CreditHeaderUtils; - -/** - * Unit tests for {@link CreditErrorAdvice}. - * - *

    The advice is a {@code @RestControllerAdvice} that maps thrown exceptions to a status/body - * and, when the request was flagged eligible (and not already charged), consumes a credit through - * either the team pool or the individual waterfall. Collaborators are mocked; the {@link - * MeterRegistry} is a real {@link SimpleMeterRegistry} so the {@code credits.consumed} counter is - * exercised. - * - *

    Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code - * UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link - * User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code - * instanceof User} without touching the repository. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class CreditErrorAdviceTest { - - private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE"; - private static final String ATTR_APIKEY = "CREDIT_API_KEY"; - private static final String ATTR_CHARGED = "CREDIT_CHARGED"; - private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT"; - private static final String ATTR_IS_API = "IS_API_REQUEST"; - - private static final String API_KEY = "apikey-abcdefgh"; - private static final String URI = "/api/v1/convert/pdf-to-img"; - - @Mock private CreditService creditService; - @Mock private TeamCreditService teamCreditService; - @Mock private UserRepository userRepository; - @Mock private ErrorTrackingService errorTrackingService; - @Mock private SaasTeamExtensionService saasTeamExtensionService; - @Mock private CreditHeaderUtils creditHeaderUtils; - - private MeterRegistry meterRegistry; - private CreditErrorAdvice advice; - - @BeforeEach - void setUp() { - meterRegistry = new SimpleMeterRegistry(); - advice = - new CreditErrorAdvice( - creditService, - teamCreditService, - userRepository, - errorTrackingService, - saasTeamExtensionService, - creditHeaderUtils, - meterRegistry); - } - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - // --- helpers -------------------------------------------------------------------------------- - - private static User user(String username) { - User u = new User(); - u.setUsername(username); - return u; - } - - private static Team team(Long id) { - Team t = new Team(); - t.setId(id); - return t; - } - - /** Put the user on the SecurityContext as an authenticated principal. */ - private void authenticate(User user) { - UsernamePasswordAuthenticationToken token = - new UsernamePasswordAuthenticationToken( - user, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContextHolder.getContext().setAuthentication(token); - } - - /** Base request that is credit-eligible with an api key, resource weight 1 and not charged. */ - private MockHttpServletRequest eligibleRequest() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setRequestURI(URI); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_APIKEY, API_KEY); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1)); - return req; - } - - @SuppressWarnings("unchecked") - private static Map bodyOf(ResponseEntity resp) { - return (Map) resp.getBody(); - } - - private double counter() { - return meterRegistry.get("credits.consumed").counter().count(); - } - - // --- status mapping ------------------------------------------------------------------------- - - @Nested - @DisplayName("determineHttpStatus mapping (via handleThrowable)") - class StatusMapping { - - @Test - @DisplayName("IllegalArgumentException -> 400 BAD_REQUEST") - void illegalArgument_400() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable(req, new IllegalArgumentException("bad")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(bodyOf(resp)) - .containsEntry("error", "IllegalArgumentException") - .containsEntry("message", "bad") - .containsEntry("status", 400); - } - - @Test - @DisplayName("AccessDeniedException -> 403 FORBIDDEN") - void accessDenied_403() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable( - req, - new org.springframework.security.access.AccessDeniedException("nope")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("UsernameNotFoundException -> 401 UNAUTHORIZED") - void usernameNotFound_401() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable( - req, - new org.springframework.security.core.userdetails - .UsernameNotFoundException("who")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("UnsupportedOperationException -> 501 NOT_IMPLEMENTED") - void unsupported_501() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable(req, new UnsupportedOperationException("later")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_IMPLEMENTED); - } - - @Test - @DisplayName("unknown exception with no message clue -> 500 INTERNAL_SERVER_ERROR") - void unknown_500() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - } - - @Test - @DisplayName("message containing 'not found' -> 404 NOT_FOUND") - void messageNotFound_404() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable(req, new RuntimeException("Resource not found here")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - } - - @Test - @DisplayName("message containing 'validation' -> 400 BAD_REQUEST") - void messageValidation_400() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable(req, new RuntimeException("Validation of input failed")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("message containing 'invalid parameter' -> 400 BAD_REQUEST") - void messageInvalidParameter_400() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = - advice.handleThrowable(req, new RuntimeException("invalid parameter: x")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("null message falls back to 'An error occurred' and 500") - void nullMessage_default() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException()); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(bodyOf(resp)).containsEntry("message", "An error occurred"); - } - } - - // --- no credit handling (gates closed) ------------------------------------------------------ - - @Nested - @DisplayName("credit handling gate is closed -> no consumption") - class GateClosed { - - @Test - @DisplayName("request not eligible: no error tracking, no consumption") - void notEligible_noConsumption() { - MockHttpServletRequest req = new MockHttpServletRequest(); - // ATTR_ELIGIBLE absent - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - verifyNoInteractions(errorTrackingService, creditService, teamCreditService); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("eligible but already charged and unauthenticated: no consumption, no header") - void alreadyCharged_unauthenticated_noHeader() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_CHARGED, Boolean.TRUE); - // no SecurityContext authentication - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - verifyNoInteractions(errorTrackingService, creditService, teamCreditService); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName( - "eligible, null api key: error tracking is skipped entirely (apiKey != null guard)") - void eligibleNullApiKey_noTracking() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setRequestURI(URI); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1)); - // no ATTR_APIKEY -> apiKey is null - - advice.handleThrowable(req, new RuntimeException("x")); - - verifyNoInteractions(errorTrackingService, creditService, teamCreditService); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("eligible with api key but tracking says do NOT consume: no charge") - void trackingSaysNo_noConsumption() { - MockHttpServletRequest req = eligibleRequest(); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(false); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - verify(errorTrackingService) - .recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), eq(500)); - verifyNoInteractions(creditService, teamCreditService); - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - } - } - - // --- individual (waterfall) consumption ----------------------------------------------------- - - @Nested - @DisplayName("individual credit consumption (no team)") - class IndividualConsumption { - - @Test - @DisplayName("waterfall success: marks charged, increments counter, sets both headers") - void waterfallSuccess_chargesAndHeaders() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3)); - req.setAttribute(ATTR_IS_API, Boolean.TRUE); - User u = user("alice"); - authenticate(u); - - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 3, true)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(42); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("CYCLE_CREDITS"); - verify(creditService).consumeCreditWithWaterfall(u, 3, true); - verify(teamCreditService, never()).consumeCredit(any(), anyInt()); - } - - @Test - @DisplayName("resource weight absent defaults credit amount to 1") - void weightAbsent_defaultsToOne() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setRequestURI(URI); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_APIKEY, API_KEY); - // no resource weight, no IS_API_REQUEST -> isApiRequestFlag false - User u = user("bob"); - authenticate(u); - - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(0); - - advice.handleThrowable(req, new RuntimeException("x")); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - } - - @Test - @DisplayName("waterfall failure: not charged, counter stays zero, no headers") - void waterfallFailure_notCharged() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("carol"); - authenticate(u); - - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS")); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull(); - } - - @Test - @DisplayName("success but negative remaining: charged + source header, no remaining header") - void successNegativeRemaining_noRemainingHeader() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("dave"); - authenticate(u); - - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(-1); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")) - .isEqualTo("METERED_SUBSCRIPTION"); - } - - @Test - @DisplayName("success with null source: charged, remaining header set, no source header") - void successNullSource_noSourceHeader() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("erin"); - authenticate(u); - - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - // success=true but source=null (unusual but defensively handled by the advice) - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(new CreditConsumptionResult(true, null, "ok")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(7); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("7"); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull(); - } - - @Test - @DisplayName("personal team is treated as no team -> waterfall, not team pool") - void personalTeam_usesWaterfall() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("frank"); - u.setTeam(team(99L)); - authenticate(u); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(5); - - advice.handleThrowable(req, new RuntimeException("x")); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - verify(teamCreditService, never()).consumeCredit(any(), anyInt()); - } - } - - // --- team consumption ----------------------------------------------------------------------- - - @Nested - @DisplayName("team credit consumption (non-personal team)") - class TeamConsumption { - - @Test - @DisplayName("non-personal team success: consumes from team pool, source TEAM_CREDITS") - void teamSuccess_consumesTeamPool() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2)); - User u = user("gina"); - u.setTeam(team(77L)); - authenticate(u); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(teamCreditService.consumeCredit(77L, 2)).thenReturn(true); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(100); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS"); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("100"); - verify(teamCreditService).consumeCredit(77L, 2); - verify(creditService, never()) - .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean()); - } - - @Test - @DisplayName("non-personal team failure: not charged, counter stays zero") - void teamFailure_notCharged() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("hank"); - u.setTeam(team(55L)); - authenticate(u); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(teamCreditService.consumeCredit(55L, 1)).thenReturn(false); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull(); - verify(creditService, never()) - .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean()); - } - } - - // --- user resolution edge cases ------------------------------------------------------------- - - @Nested - @DisplayName("user resolution edge cases (tracking says consume)") - class UserResolution { - - @Test - @DisplayName("no authentication: getCurrentUser throws, user null -> no consumption") - void noAuth_userNull_noConsumption() { - MockHttpServletRequest req = eligibleRequest(); - // no SecurityContext authentication -> AuthenticationUtils throws SecurityException - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - verifyNoInteractions(creditService, teamCreditService); - } - } - - // --- already-charged header path ------------------------------------------------------------ - - @Nested - @DisplayName("already-charged path sets remaining header when authenticated") - class AlreadyCharged { - - @Test - @DisplayName( - "already charged + authenticated + non-negative remaining: header set, no consumption") - void alreadyCharged_authenticated_setsHeader() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_CHARGED, Boolean.TRUE); - User u = user("ian"); - authenticate(u); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(9); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("9"); - // Already-charged branch never records an error or consumes again. - verifyNoInteractions(errorTrackingService, teamCreditService); - verify(creditService, never()) - .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean()); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("already charged + authenticated + negative remaining: no header") - void alreadyCharged_negativeRemaining_noHeader() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_CHARGED, Boolean.TRUE); - User u = user("jane"); - authenticate(u); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(-1); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - } - - @Test - @DisplayName("already charged: header lookup throwing is swallowed (no propagation)") - void alreadyCharged_headerLookupThrows_swallowed() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_CHARGED, Boolean.TRUE); - User u = user("kyle"); - authenticate(u); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenThrow(new RuntimeException("header boom")); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - // Must not propagate; status still computed and no remaining header set. - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull(); - } - } - - // --- SSE response shaping -------------------------------------------------------------------- - - @Nested - @DisplayName("SSE response shaping") - class SseShaping { - - @Test - @DisplayName("Accept text/event-stream: body is SSE framed text with event: error") - void acceptHeader_producesSseBody() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE); - - ResponseEntity resp = - advice.handleThrowable(req, new IllegalArgumentException("bad")); - - assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM); - assertThat(resp.getBody()).isInstanceOf(String.class); - String sse = (String) resp.getBody(); - assertThat(sse).startsWith("event: error\ndata: ").endsWith("\n\n"); - assertThat(sse).contains("\"error\":\"IllegalArgumentException\""); - assertThat(sse).contains("\"status\":400"); - } - - @Test - @DisplayName("Content-Type text/event-stream also triggers SSE framing") - void contentTypeHeader_producesSseBody() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom")); - - assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM); - assertThat((String) resp.getBody()).startsWith("event: error\ndata: "); - } - - @Test - @DisplayName("non-SSE request returns the Map body, not SSE text") - void nonSse_returnsMapBody() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("boom")); - - assertThat(resp.getBody()).isInstanceOf(Map.class); - assertThat(resp.getHeaders().getContentType()).isNull(); - } - - @Test - @DisplayName( - "SSE framing carries through after a successful team charge (headers + SSE body)") - void sseWithTeamCharge_headersAndSseBody() { - MockHttpServletRequest req = eligibleRequest(); - req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE); - User u = user("liz"); - u.setTeam(team(33L)); - authenticate(u); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(teamCreditService.consumeCredit(33L, 1)).thenReturn(true); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(8); - - ResponseEntity resp = advice.handleThrowable(req, new RuntimeException("x")); - - assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM); - assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS"); - assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("8"); - assertThat((String) resp.getBody()).startsWith("event: error\ndata: "); - } - } - - // --- counter accumulation across calls ------------------------------------------------------- - - @Test - @DisplayName("counter accumulates across multiple successful charges") - void counterAccumulates() { - User u = user("mike"); - authenticate(u); - when(errorTrackingService.recordErrorAndShouldConsumeCredit( - eq(API_KEY), eq(URI), any(Throwable.class), anyInt())) - .thenReturn(true); - when(creditService.consumeCreditWithWaterfall(eq(u), eq(1), eq(false))) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(3); - - advice.handleThrowable(eligibleRequest(), new RuntimeException("a")); - advice.handleThrowable(eligibleRequest(), new RuntimeException("b")); - - assertThat(counter()).isEqualTo(2.0d); - verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false); - } - - @Test - @DisplayName("ErrorResponse value holder wires its fields verbatim") - void errorResponseHolder() { - CreditErrorAdvice.ErrorResponse er = - new CreditErrorAdvice.ErrorResponse("Boom", "it broke", 500); - - assertThat(er.error).isEqualTo("Boom"); - assertThat(er.message).isEqualTo("it broke"); - assertThat(er.status).isEqualTo(500); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java deleted file mode 100644 index f89d841b4..000000000 --- a/app/saas/src/test/java/stirling/software/saas/interceptor/CreditSuccessAdviceTest.java +++ /dev/null @@ -1,679 +0,0 @@ -package stirling.software.saas.interceptor; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import org.springframework.http.server.ServerHttpRequest; -import org.springframework.http.server.ServletServerHttpRequest; -import org.springframework.http.server.ServletServerHttpResponse; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; -import stirling.software.saas.util.CreditHeaderUtils; - -/** - * Unit tests for {@link CreditSuccessAdvice}. - * - *

    The advice is a {@code @RestControllerAdvice} {@code ResponseBodyAdvice} that, on a successful - * (status < 400) response previously flagged credit-eligible (and not already charged), consumes - * a credit either from the team pool (non-personal team) or via the individual waterfall, then sets - * {@code X-Credits-Remaining} / {@code X-Credit-Source} headers and increments a {@code - * credits.consumed} counter. Collaborators are mocked; the {@link MeterRegistry} is a real {@link - * SimpleMeterRegistry} so the counter is exercised. - * - *

    {@link ServerHttpRequest}/response are constructed by wrapping {@link MockHttpServletRequest} - * and {@link MockHttpServletResponse} in {@link ServletServerHttpRequest}/{@link - * ServletServerHttpResponse} so the advice's {@code instanceof} unwrapping and status read work - * against the mock servlet objects. {@code beforeBodyWrite}'s {@code MethodParameter}, {@code - * MediaType} and converter type arguments are unused by the charging logic, so {@code null} is - * passed for them. - * - *

    Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code - * UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link - * User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code - * instanceof User} without touching the repository. The authorities on that token drive the - * limited-API-user check (which the advice reads from the authentication, not the user). - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class CreditSuccessAdviceTest { - - private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE"; - private static final String ATTR_APIKEY = "CREDIT_API_KEY"; - private static final String ATTR_CHARGED = "CREDIT_CHARGED"; - private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT"; - private static final String ATTR_IS_API = "IS_API_REQUEST"; - - private static final String API_KEY = "apikey-abcdefgh"; - - @Mock private CreditService creditService; - @Mock private TeamCreditService teamCreditService; - @Mock private UserRepository userRepository; - @Mock private SaasTeamExtensionService saasTeamExtensionService; - @Mock private CreditHeaderUtils creditHeaderUtils; - - private MeterRegistry meterRegistry; - private CreditSuccessAdvice advice; - - @BeforeEach - void setUp() { - meterRegistry = new SimpleMeterRegistry(); - advice = - new CreditSuccessAdvice( - creditService, - teamCreditService, - userRepository, - saasTeamExtensionService, - creditHeaderUtils, - meterRegistry); - } - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - // --- helpers -------------------------------------------------------------------------------- - - private static User user(String username) { - User u = new User(); - u.setUsername(username); - return u; - } - - private static Team team(Long id) { - Team t = new Team(); - t.setId(id); - return t; - } - - /** Authenticate with the default ROLE_USER authority (not a limited-API user). */ - private void authenticate(User user) { - authenticate(user, "ROLE_USER"); - } - - /** Put the user on the SecurityContext as an authenticated principal with given authorities. */ - private void authenticate(User user, String... authorities) { - var grants = java.util.Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList(); - UsernamePasswordAuthenticationToken token = - new UsernamePasswordAuthenticationToken(user, null, grants); - SecurityContextHolder.getContext().setAuthentication(token); - } - - /** Base request: credit-eligible with an api key, resource weight 1, not charged. */ - private MockHttpServletRequest eligibleRequest() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_APIKEY, API_KEY); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1)); - return req; - } - - /** Holder so a test can read back both the returned body and the underlying servlet objects. */ - private static final class Exchange { - final MockHttpServletRequest servletReq; - final MockHttpServletResponse servletResp; - final ServletServerHttpResponse response; - - Exchange(MockHttpServletRequest servletReq, MockHttpServletResponse servletResp) { - this.servletReq = servletReq; - this.servletResp = servletResp; - this.response = new ServletServerHttpResponse(servletResp); - } - - String header(String name) { - // Read from the live ServerHttpResponse headers the advice wrote to. - return response.getHeaders().getFirst(name); - } - } - - /** - * Invoke beforeBodyWrite against the given servlet request/response and return the exchange. - */ - private Object invoke(Exchange ex, Object body) { - ServletServerHttpRequest request = new ServletServerHttpRequest(ex.servletReq); - return advice.beforeBodyWrite(body, null, null, null, request, ex.response); - } - - private Object invoke(MockHttpServletRequest servletReq, Object body) { - return invoke(new Exchange(servletReq, new MockHttpServletResponse()), body); - } - - private double counter() { - return meterRegistry.get("credits.consumed").counter().count(); - } - - // --- supports() ----------------------------------------------------------------------------- - - @Test - @DisplayName("supports() returns true for any return type / converter") - void supports_alwaysTrue() { - assertThat(advice.supports(null, null)).isTrue(); - } - - // --- gates that short-circuit (body returned unchanged, nothing consumed) -------------------- - - @Nested - @DisplayName("short-circuit gates -> body returned unchanged, no consumption") - class Gates { - - @Test - @DisplayName("non-servlet request: returns body untouched, no interactions") - void nonServletRequest_returnsBody() { - ServerHttpRequest notServlet = org.mockito.Mockito.mock(ServerHttpRequest.class); - ServletServerHttpResponse resp = - new ServletServerHttpResponse(new MockHttpServletResponse()); - - Object body = "BODY"; - Object out = advice.beforeBodyWrite(body, null, null, null, notServlet, resp); - - assertThat(out).isSameAs(body); - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("not eligible (attribute absent): no consumption") - void notEligible_noConsumption() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setAttribute(ATTR_APIKEY, API_KEY); - - Object out = invoke(req, "BODY"); - - assertThat(out).isEqualTo("BODY"); - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("eligible attribute not Boolean.TRUE (e.g. Boolean.FALSE): no consumption") - void eligibleFalse_noConsumption() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_ELIGIBLE, Boolean.FALSE); - authenticate(user("x")); - - invoke(req, "BODY"); - - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("already charged (CREDIT_CHARGED set): no second consumption") - void alreadyCharged_noConsumption() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_CHARGED, Boolean.TRUE); - authenticate(user("x")); - - invoke(req, "BODY"); - - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("error status >= 400 on response: skip consumption, error advice decides") - void errorStatus_skipsConsumption() { - MockHttpServletRequest req = eligibleRequest(); - authenticate(user("x")); - MockHttpServletResponse servletResp = new MockHttpServletResponse(); - servletResp.setStatus(500); - Exchange ex = new Exchange(req, servletResp); - - Object out = invoke(ex, "BODY"); - - assertThat(out).isEqualTo("BODY"); - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("status exactly 400 is treated as error -> skip") - void status400_skipsConsumption() { - MockHttpServletRequest req = eligibleRequest(); - authenticate(user("x")); - MockHttpServletResponse servletResp = new MockHttpServletResponse(); - servletResp.setStatus(400); - - invoke(new Exchange(req, servletResp), "BODY"); - - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - - @Test - @DisplayName("status 399 (just below 400) still consumes") - void status399_stillConsumes() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("edge"); - authenticate(u); - MockHttpServletResponse servletResp = new MockHttpServletResponse(); - servletResp.setStatus(399); - Exchange ex = new Exchange(req, servletResp); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(5); - - invoke(ex, "BODY"); - - assertThat(counter()).isEqualTo(1.0d); - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - } - - @Test - @DisplayName("eligible but apiKey attribute absent: no consumption (apiKey != null guard)") - void nullApiKey_noConsumption() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1)); - // no ATTR_APIKEY -> apiKey null - authenticate(user("x")); - - invoke(req, "BODY"); - - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - assertThat(counter()).isZero(); - } - } - - // --- individual (waterfall) consumption ----------------------------------------------------- - - @Nested - @DisplayName("individual credit consumption (no team)") - class IndividualConsumption { - - @Test - @DisplayName("waterfall success: marks charged, increments counter, sets both headers") - void waterfallSuccess_chargesAndHeaders() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3)); - req.setAttribute(ATTR_IS_API, Boolean.TRUE); - User u = user("alice"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 3, true)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(42); - - Object out = invoke(ex, "BODY"); - - assertThat(out).isEqualTo("BODY"); - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(ex.header("X-Credits-Remaining")).isEqualTo("42"); - assertThat(ex.header("X-Credit-Source")).isEqualTo("CYCLE_CREDITS"); - verify(creditService).consumeCreditWithWaterfall(u, 3, true); - verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt()); - } - - @Test - @DisplayName("resource weight absent defaults credit amount to 1, IS_API absent -> false") - void weightAbsent_defaultsToOne() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE); - req.setAttribute(ATTR_APIKEY, API_KEY); - // no resource weight, no IS_API_REQUEST -> isApiRequestFlag false - User u = user("bob"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(0); - - invoke(ex, "BODY"); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - // remaining 0 is non-negative -> header set - assertThat(ex.header("X-Credits-Remaining")).isEqualTo("0"); - assertThat(ex.header("X-Credit-Source")).isEqualTo("BOUGHT_CREDITS"); - } - - @Test - @DisplayName("IS_API_REQUEST=false is passed through as false to the waterfall") - void isApiFalse_passedThrough() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_IS_API, Boolean.FALSE); - User u = user("ivy"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(3); - - invoke(ex, "BODY"); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - } - - @Test - @DisplayName( - "waterfall failure (insufficient credits): not charged, counter zero, no headers") - void waterfallFailure_notCharged() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("carol"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS")); - - invoke(ex, "BODY"); - - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - assertThat(ex.header("X-Credits-Remaining")).isNull(); - assertThat(ex.header("X-Credit-Source")).isNull(); - // header utils is only consulted after a successful charge - verifyNoInteractions(creditHeaderUtils); - } - - @Test - @DisplayName("success but negative remaining: charged + source header, no remaining header") - void successNegativeRemaining_noRemainingHeader() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("dave"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(-1); - - invoke(ex, "BODY"); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(ex.header("X-Credits-Remaining")).isNull(); - assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION"); - } - - @Test - @DisplayName("success with null source: charged, remaining header set, no source header") - void successNullSource_noSourceHeader() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("erin"); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - // success=true but source=null (defensively handled by the advice) - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(new CreditConsumptionResult(true, null, "ok")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(7); - - invoke(ex, "BODY"); - - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(ex.header("X-Credits-Remaining")).isEqualTo("7"); - assertThat(ex.header("X-Credit-Source")).isNull(); - } - - @Test - @DisplayName("user with personal team is treated as no team -> waterfall, not team pool") - void personalTeam_usesWaterfall() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("frank"); - u.setTeam(team(99L)); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(5); - - invoke(ex, "BODY"); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt()); - } - } - - // --- limited-API users always use personal credits ------------------------------------------ - - @Nested - @DisplayName("limited-API users always use personal credits (never team pool)") - class LimitedApiUsers { - - @Test - @DisplayName("ROLE_LIMITED_API_USER in a non-personal team still uses the waterfall") - void limitedApiUser_usesWaterfall() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("lim"); - u.setTeam(team(77L)); - authenticate(u, "ROLE_LIMITED_API_USER"); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(2); - - invoke(ex, "BODY"); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt()); - // isPersonal short-circuited by the limited-API check; team service untouched - verify(saasTeamExtensionService, never()).isPersonal(any()); - } - - @Test - @DisplayName("ROLE_EXTRA_LIMITED_API_USER in a non-personal team still uses the waterfall") - void extraLimitedApiUser_usesWaterfall() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("xlim"); - u.setTeam(team(88L)); - authenticate(u, "ROLE_EXTRA_LIMITED_API_USER"); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(1); - - invoke(ex, "BODY"); - - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt()); - } - } - - // --- team consumption ----------------------------------------------------------------------- - - @Nested - @DisplayName("team credit consumption (non-personal team)") - class TeamConsumption { - - @Test - @DisplayName("non-personal team success: consumes from team pool, sets source header") - void teamSuccess_consumesTeamPool() { - MockHttpServletRequest req = eligibleRequest(); - req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2)); - User u = user("gina"); - u.setTeam(team(77L)); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.consumeCreditWithWaterfall(77L, 2)) - .thenReturn(CreditConsumptionResult.success("TEAM_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(100); - - Object out = invoke(ex, "BODY"); - - assertThat(out).isEqualTo("BODY"); - assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE); - assertThat(counter()).isEqualTo(1.0d); - assertThat(ex.header("X-Credit-Source")).isEqualTo("TEAM_CREDITS"); - assertThat(ex.header("X-Credits-Remaining")).isEqualTo("100"); - verify(teamCreditService).consumeCreditWithWaterfall(77L, 2); - verify(creditService, never()) - .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean()); - } - - @Test - @DisplayName("non-personal team success via leader overage source is propagated") - void teamSuccess_overageSourcePropagated() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("greg"); - u.setTeam(team(12L)); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.consumeCreditWithWaterfall(12L, 1)) - .thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(0); - - invoke(ex, "BODY"); - - assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION"); - } - - @Test - @DisplayName("non-personal team failure: not charged, counter zero, no headers") - void teamFailure_notCharged() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("hank"); - u.setTeam(team(55L)); - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.consumeCreditWithWaterfall(55L, 1)) - .thenReturn( - CreditConsumptionResult.failure("TEAM_CREDITS_EXHAUSTED_NO_OVERAGE")); - - invoke(ex, "BODY"); - - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - assertThat(ex.header("X-Credits-Remaining")).isNull(); - assertThat(ex.header("X-Credit-Source")).isNull(); - verify(creditService, never()) - .consumeCreditWithWaterfall(any(), anyInt(), anyBoolean()); - verifyNoInteractions(creditHeaderUtils); - } - - @Test - @DisplayName("team with null id is treated as no team -> waterfall used") - void teamNullId_usesWaterfall() { - MockHttpServletRequest req = eligibleRequest(); - User u = user("nina"); - u.setTeam(team(null)); // non-personal (isPersonal false) but id null - authenticate(u); - Exchange ex = new Exchange(req, new MockHttpServletResponse()); - - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(4); - - invoke(ex, "BODY"); - - // targetTeamId resolves to null (team id null) -> individual waterfall - verify(creditService).consumeCreditWithWaterfall(u, 1, false); - verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt()); - } - } - - // --- user resolution edge cases ------------------------------------------------------------- - - @Nested - @DisplayName("user resolution edge cases") - class UserResolution { - - @Test - @DisplayName("no authentication: getCurrentUser throws, user null -> no consumption") - void noAuth_userNull_noConsumption() { - MockHttpServletRequest req = eligibleRequest(); - // no SecurityContext authentication -> AuthenticationUtils throws SecurityException - - Object out = invoke(req, "BODY"); - - assertThat(out).isEqualTo("BODY"); - assertThat(req.getAttribute(ATTR_CHARGED)).isNull(); - assertThat(counter()).isZero(); - verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils); - } - } - - // --- counter accumulation ------------------------------------------------------------------- - - @Test - @DisplayName("counter accumulates across multiple successful charges") - void counterAccumulates() { - User u = user("mike"); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(3); - - // fresh request each time so ATTR_CHARGED from a prior call doesn't block the next - authenticate(u); - invoke(eligibleRequest(), "A"); - invoke(eligibleRequest(), "B"); - - assertThat(counter()).isEqualTo(2.0d); - verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false); - } - - @Test - @DisplayName("body is always returned verbatim (including null) regardless of charging") - void bodyReturnedVerbatim() { - User u = user("nora"); - authenticate(u); - when(creditService.consumeCreditWithWaterfall(u, 1, false)) - .thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS")); - when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService)) - .thenReturn(1); - - Object out = invoke(eligibleRequest(), null); - - assertThat(out).isNull(); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java deleted file mode 100644 index d56fe7e06..000000000 --- a/app/saas/src/test/java/stirling/software/saas/interceptor/UnifiedCreditInterceptorTest.java +++ /dev/null @@ -1,806 +0,0 @@ -package stirling.software.saas.interceptor; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.lang.reflect.Method; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.method.HandlerMethod; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.common.annotations.AutoJobPostMapping; -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.Authority; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.repository.TeamMembershipRepository; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.ErrorTrackingService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.SaasUserExtensionService; -import stirling.software.saas.service.TeamCreditService; - -/** - * Pure-Mockito unit tests for {@link UnifiedCreditInterceptor}. No Spring context: request/response - * are Spring's mock servlet objects, the SecurityContext is populated with real token types - * (matching the sibling {@code PaygChargeInterceptorTest} house style) and every collaborator is a - * Mockito mock. A real {@link SimpleMeterRegistry} backs the counters/timer so increments can be - * asserted directly. - */ -class UnifiedCreditInterceptorTest { - - private CreditService creditService; - private ErrorTrackingService errorTrackingService; - private CreditsProperties creditsProperties; - private UserRepository userRepository; - private TeamCreditService teamCreditService; - private TeamMembershipRepository membershipRepository; - private SaasUserExtensionService saasUserExtensionService; - private SaasTeamExtensionService saasTeamExtensionService; - private MeterRegistry meterRegistry; - private UnifiedCreditInterceptor interceptor; - - @BeforeEach - void setUp() { - creditService = Mockito.mock(CreditService.class); - errorTrackingService = Mockito.mock(ErrorTrackingService.class); - creditsProperties = new CreditsProperties(); - userRepository = Mockito.mock(UserRepository.class); - teamCreditService = Mockito.mock(TeamCreditService.class); - membershipRepository = Mockito.mock(TeamMembershipRepository.class); - saasUserExtensionService = Mockito.mock(SaasUserExtensionService.class); - saasTeamExtensionService = Mockito.mock(SaasTeamExtensionService.class); - meterRegistry = new SimpleMeterRegistry(); - interceptor = - new UnifiedCreditInterceptor( - creditService, - errorTrackingService, - creditsProperties, - userRepository, - teamCreditService, - membershipRepository, - saasUserExtensionService, - saasTeamExtensionService, - meterRegistry); - SecurityContextHolder.clearContext(); - } - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - // --- gate short-circuits --------------------------------------------------------------------- - - @Nested - @DisplayName("preHandle gate / short-circuit branches") - class GateBranches { - - @Test - @DisplayName("credits disabled allows request without touching any collaborator") - void creditsDisabled_allowsAndSkipsValidation() throws Exception { - creditsProperties.setEnabled(false); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - verifyNoInteractions(creditService, teamCreditService, userRepository); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull(); - } - - @Test - @DisplayName("non-HandlerMethod handler is out of scope and allowed") - void plainHandlerObject_isAllowed() throws Exception { - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, new Object()); - - assertThat(cont).isTrue(); - verifyNoInteractions(creditService, teamCreditService); - } - - @Test - @DisplayName("HandlerMethod without @AutoJobPostMapping is out of scope and allowed") - void handlerWithoutAnnotation_isAllowed() throws Exception { - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForPlain()); - - assertThat(cont).isTrue(); - verifyNoInteractions(creditService, teamCreditService); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull(); - } - } - - // --- authentication gating ------------------------------------------------------------------- - - @Nested - @DisplayName("authentication blocking") - class AuthBlocking { - - @Test - @DisplayName("null authentication is blocked with 401") - void nullAuth_blockedWith401() throws Exception { - SecurityContextHolder.clearContext(); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(401); - assertThat(res.getContentType()).isEqualTo("application/json"); - assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED"); - verifyNoInteractions(creditService, teamCreditService); - } - - @Test - @DisplayName("unauthenticated token (isAuthenticated=false) is blocked with 401") - void unauthenticatedToken_blockedWith401() throws Exception { - // 2-arg ctor leaves isAuthenticated()=false, so it falls through to the security block. - SecurityContextHolder.getContext() - .setAuthentication(new UsernamePasswordAuthenticationToken("someone", "creds")); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(401); - assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED"); - } - } - - // --- JWT lookup / role bypass ---------------------------------------------------------------- - - @Nested - @DisplayName("JWT authentication: lookup, role bypass and bad identifiers") - class JwtLookupAndBypass { - - @Test - @DisplayName("JWT user not found in repository returns 500 USER_NOT_FOUND") - void jwtUserMissing_returns500() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.empty()); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(500); - assertThat(res.getContentAsString()).contains("USER_NOT_FOUND"); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("JWT with non-UUID identifier returns 400 INVALID_USER_ID") - void jwtInvalidSupabaseIdFormat_returns400() throws Exception { - // auth.getName() is not a UUID → UUID.fromString throws IllegalArgumentException. - authenticateJwt("not-a-uuid", "ROLE_USER"); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(400); - assertThat(res.getContentAsString()).contains("INVALID_USER_ID"); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("admin JWT user bypasses credit validation and bumps jwt_bypass counter") - void adminJwtUser_bypassesValidation() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_ADMIN"); - User admin = makeUser(1L, null, "ROLE_ADMIN"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(admin)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull(); - assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count()) - .isEqualTo(1.0); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("internal backend-API JWT user bypasses credit validation") - void internalBackendApiUser_bypassesValidation() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "STIRLING-PDF-BACKEND-API-USER"); - User internal = makeUser(2L, null, "STIRLING-PDF-BACKEND-API-USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(internal)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count()) - .isEqualTo(1.0); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("pro JWT user is NOT bypassed - still flows into waterfall credit checks") - void proJwtUser_isSubjectToCreditChecks() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_PRO_USER"); - User pro = makeUser(3L, null, "ROLE_PRO_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(pro)); - when(creditService.getOrCreateUserCredits(pro)).thenReturn(userCredit(100)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - // Pro is not bypassed; it consumed the credit-check path so the eligible flag is set. - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count()) - .isEqualTo(0.0); - verify(creditService).getOrCreateUserCredits(pro); - } - } - - // --- JWT personal-credit path ---------------------------------------------------------------- - - @Nested - @DisplayName("JWT personal-credit path") - class JwtPersonalCredits { - - @Test - @DisplayName("sufficient personal credits passes and marks request eligible") - void sufficientPersonalCredits_passes() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - User user = makeUser(10L, null, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(50)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - // JWT credit identifier is the Supabase id, weight clamps to 1 (default sentinel). - assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo(supabaseId); - assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(1); - assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(false); - assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(false); - assertThat(meterRegistry.counter("credits.validation.checked").count()).isEqualTo(1.0); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("insufficient personal credits without metered billing is rejected with 429") - void insufficientPersonalCredits_rejectedWith429() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - User user = makeUser(11L, null, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0)); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - assertThat(res.getContentAsString()).contains("INSUFFICIENT_CREDITS"); - // Personal (not team) message wording. - assertThat(res.getContentAsString()).contains("Insufficient API credits"); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull(); - assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0); - } - - @Test - @DisplayName( - "insufficient personal credits but metered billing enabled proceeds on overage") - void insufficientPersonalCredits_withMeteredBilling_proceeds() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - User user = makeUser(12L, null, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0)); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(0.0); - } - } - - // --- JWT team-credit path -------------------------------------------------------------------- - - @Nested - @DisplayName("JWT team-credit path") - class JwtTeamCredits { - - @Test - @DisplayName("non-personal team with sufficient team credits passes") - void teamCreditsSufficient_passes() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - Team team = makeTeam(500L); - User user = makeUser(20L, team, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - when(teamCreditService.getTeamCredits(500L)).thenReturn(Optional.of(teamCredit(80))); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - verify(teamCreditService).getTeamCredits(500L); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("non-personal team with no credits but leader metered billing proceeds") - void teamCreditsExhausted_leaderMetered_proceeds() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - Team team = makeTeam(501L); - User user = makeUser(21L, team, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - when(teamCreditService.getTeamCredits(501L)).thenReturn(Optional.of(teamCredit(0))); - - // Leader has metered billing → overage allowed even with zero team credits. - User leader = makeUser(99L, team, "ROLE_USER"); - TeamMembership leaderMembership = new TeamMembership(); - leaderMembership.setUser(leader); - leaderMembership.setRole(TeamRole.LEADER); - when(membershipRepository.findByTeamIdAndRole(501L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership)); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - } - - @Test - @DisplayName( - "non-personal team with no credits and no leader metered is rejected (team msg)") - void teamCreditsExhausted_noLeaderMetered_rejectedWithTeamMessage() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - Team team = makeTeam(502L); - User user = makeUser(22L, team, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - when(teamCreditService.getTeamCredits(502L)).thenReturn(Optional.of(teamCredit(0))); - when(membershipRepository.findByTeamIdAndRole(502L, TeamRole.LEADER)) - .thenReturn(List.of()); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - assertThat(res.getContentAsString()).contains("Insufficient team credits"); - assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0); - } - - @Test - @DisplayName("personal team falls back to personal credits, not team credits") - void personalTeam_usesPersonalCredits() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - Team team = makeTeam(503L); - User user = makeUser(23L, team, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(true); - when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(10)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - verify(creditService).getOrCreateUserCredits(user); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("limited-API JWT user in a team uses personal credits, never team credits") - void limitedApiUserInTeam_usesPersonalCredits() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_LIMITED_API_USER"); - Team team = makeTeam(504L); - User user = makeUser(24L, team, "ROLE_LIMITED_API_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(5)); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - verify(creditService).getOrCreateUserCredits(user); - verify(teamCreditService, never()).getTeamCredits(any()); - verify(saasTeamExtensionService, never()).isPersonal(any()); - } - - @Test - @DisplayName("leader-metered lookup that throws is swallowed and treated as not-metered") - void leaderMeteredLookupThrows_treatedAsNotMetered() throws Exception { - String supabaseId = UUID.randomUUID().toString(); - authenticateJwt(supabaseId, "ROLE_USER"); - Team team = makeTeam(505L); - User user = makeUser(25L, team, "ROLE_USER"); - when(userRepository.findBySupabaseId(UUID.fromString(supabaseId))) - .thenReturn(Optional.of(user)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - when(teamCreditService.getTeamCredits(505L)).thenReturn(Optional.of(teamCredit(0))); - when(membershipRepository.findByTeamIdAndRole(505L, TeamRole.LEADER)) - .thenThrow(new RuntimeException("db down")); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - // Exception in checkTeamLeaderMeteredBilling is caught → false → rejected, not a 500. - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - } - } - - // --- API key path ---------------------------------------------------------------------------- - - @Nested - @DisplayName("API key authentication path") - class ApiKeyPath { - - @Test - @DisplayName("API key with sufficient credits passes and flags API request attributes") - void apiKeySufficientCredits_passes() throws Exception { - User user = makeUser(30L, null, "ROLE_API"); - user.setApiKey("sk-abcd1234efgh5678"); - authenticateApiKey(user, "sk-abcd1234efgh5678"); - when(creditService.getUserCreditsByApiKey("sk-abcd1234efgh5678")) - .thenReturn(Optional.of(userCredit(20))); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo("sk-abcd1234efgh5678"); - assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(true); - assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(true); - verify(creditService).getUserCreditsByApiKey("sk-abcd1234efgh5678"); - verify(creditService, never()).getOrCreateUserCredits(any()); - } - - @Test - @DisplayName("API key with no credit row defaults to zero balance and is rejected") - void apiKeyNoCreditRow_rejectedWith429() throws Exception { - User user = makeUser(31L, null, "ROLE_API"); - user.setApiKey("sk-zzzz0000zzzz0000"); - authenticateApiKey(user, "sk-zzzz0000zzzz0000"); - when(creditService.getUserCreditsByApiKey("sk-zzzz0000zzzz0000")) - .thenReturn(Optional.empty()); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - // API key users always use personal credits → personal message even with a team absent. - assertThat(res.getContentAsString()).contains("Insufficient API credits"); - } - - @Test - @DisplayName("API key insufficient credits but metered billing enabled proceeds") - void apiKeyInsufficientCredits_withMeteredBilling_proceeds() throws Exception { - User user = makeUser(32L, null, "ROLE_API"); - user.setApiKey("sk-meter0000meter0"); - authenticateApiKey(user, "sk-meter0000meter0"); - when(creditService.getUserCreditsByApiKey("sk-meter0000meter0")) - .thenReturn(Optional.of(userCredit(0))); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - } - } - - // --- resource weight clamping ---------------------------------------------------------------- - - @Nested - @DisplayName("resource weight clamping") - class ResourceWeightClamping { - - @Test - @DisplayName("weight above 100 clamps to 100") - void weightAbove100_clampsTo100() throws Exception { - User user = makeUser(40L, null, "ROLE_API"); - user.setApiKey("sk-clamp0000clamp00"); - authenticateApiKey(user, "sk-clamp0000clamp00"); - when(creditService.getUserCreditsByApiKey("sk-clamp0000clamp00")) - .thenReturn(Optional.of(userCredit(1000))); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(100); - } - - @Test - @DisplayName("a weight-100 job is rejected when balance is below 100 (no rounding leak)") - void weight100_balance50_rejected() throws Exception { - User user = makeUser(41L, null, "ROLE_API"); - user.setApiKey("sk-tight0000tight00"); - authenticateApiKey(user, "sk-tight0000tight00"); - when(creditService.getUserCreditsByApiKey("sk-tight0000tight00")) - .thenReturn(Optional.of(userCredit(50))); - when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy()); - - assertThat(cont).isFalse(); - assertThat(res.getStatus()).isEqualTo(429); - } - - @Test - @DisplayName("exact balance == weight is sufficient (>= boundary)") - void exactBalanceEqualsWeight_passes() throws Exception { - User user = makeUser(42L, null, "ROLE_API"); - user.setApiKey("sk-exact0000exact00"); - authenticateApiKey(user, "sk-exact0000exact00"); - // Heavy endpoint weight clamps to 100; exactly 100 credits is sufficient. - when(creditService.getUserCreditsByApiKey("sk-exact0000exact00")) - .thenReturn(Optional.of(userCredit(100))); - - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy()); - - assertThat(cont).isTrue(); - assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE); - } - } - - // --- lifecycle no-ops ------------------------------------------------------------------------ - - @Nested - @DisplayName("postHandle / afterCompletion / afterConcurrentHandlingStarted are no-ops") - class LifecycleNoOps { - - @Test - @DisplayName("postHandle does no spending and touches no collaborator") - void postHandle_isNoOp() throws Exception { - interceptor.postHandle( - new MockHttpServletRequest(), - new MockHttpServletResponse(), - handlerMethodForAuto(), - null); - - verifyNoInteractions( - creditService, teamCreditService, errorTrackingService, userRepository); - } - - @Test - @DisplayName("afterCompletion with no exception does no spending") - void afterCompletion_success_isNoOp() throws Exception { - interceptor.afterCompletion( - new MockHttpServletRequest(), - new MockHttpServletResponse(), - handlerMethodForAuto(), - null); - - verifyNoInteractions(creditService, teamCreditService, errorTrackingService); - } - - @Test - @DisplayName("afterCompletion with an exception still does no spending") - void afterCompletion_withException_isNoOp() throws Exception { - interceptor.afterCompletion( - new MockHttpServletRequest(), - new MockHttpServletResponse(), - handlerMethodForAuto(), - new RuntimeException("boom")); - - verifyNoInteractions(creditService, teamCreditService, errorTrackingService); - } - - @Test - @DisplayName("afterConcurrentHandlingStarted does no spending") - void afterConcurrentHandlingStarted_isNoOp() throws Exception { - interceptor.afterConcurrentHandlingStarted( - new MockHttpServletRequest(), - new MockHttpServletResponse(), - handlerMethodForAuto()); - - verifyNoInteractions(creditService, teamCreditService, errorTrackingService); - } - } - - // --- helpers --------------------------------------------------------------------------------- - - private void authenticateJwt(String name, String role) { - // Not an EnhancedJwtAuthenticationToken, so extractSupabaseId falls back to auth.getName(). - // 3-arg ctor → isAuthenticated()=true so the JWT branch is taken. - UsernamePasswordAuthenticationToken token = - new UsernamePasswordAuthenticationToken( - name, null, List.of(new SimpleGrantedAuthority(role))); - SecurityContextHolder.getContext().setAuthentication(token); - } - - private void authenticateApiKey(User user, String apiKey) { - ApiKeyAuthenticationToken token = - new ApiKeyAuthenticationToken( - user, apiKey, List.of(new SimpleGrantedAuthority("ROLE_API"))); - SecurityContextHolder.getContext().setAuthentication(token); - } - - private static UserCredit userCredit(int cycleCredits) { - UserCredit c = new UserCredit(); - c.setCycleCreditsRemaining(cycleCredits); - c.setBoughtCreditsRemaining(0); - return c; - } - - private static TeamCredit teamCredit(int cycleCredits) { - TeamCredit c = new TeamCredit(); - c.setCycleCreditsRemaining(cycleCredits); - c.setBoughtCreditsRemaining(0); - return c; - } - - private static Team makeTeam(Long id) { - Team team = new Team(); - team.setId(id); - return team; - } - - private static User makeUser(Long id, Team team, String role) { - User user = new User(); - try { - java.lang.reflect.Field idField = User.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(user, id); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - user.setUsername("user-" + id); - user.setSupabaseId(UUID.randomUUID()); - if (team != null) { - user.setTeam(team); - } - if (role != null) { - // Authority ctor self-registers into user.getAuthorities(). - new Authority(role, user); - } - return user; - } - - private static HandlerMethod handlerMethodForAuto() { - return handlerMethod("handleAuto"); - } - - private static HandlerMethod handlerMethodForHeavy() { - return handlerMethod("handleHeavy"); - } - - private static HandlerMethod handlerMethodForPlain() { - return handlerMethod("handlePlain"); - } - - private static HandlerMethod handlerMethod(String name) { - try { - Method m = FakeController.class.getDeclaredMethod(name); - return new HandlerMethod(new FakeController(), m); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - static class FakeController { - // No explicit resourceWeight → default sentinel Integer.MIN_VALUE → clamps to 1. - @AutoJobPostMapping(value = "/auto") - public void handleAuto() {} - - // Above-max weight to exercise the clamp-to-100 branch. - @AutoJobPostMapping(value = "/heavy", resourceWeight = 5000) - public void handleHeavy() {} - - public void handlePlain() {} - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 9ff9d7832..8b3ce50b6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -174,7 +174,7 @@ class JobChargeServiceTest { assertThat(row.getJobId()).isEqualTo(newJob.getId()); assertThat(row.getPolicyId()).isEqualTo(policy.getId()); assertThat(row.getPaygUnits()).isEqualTo(4); - // Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up. + // Legacy comparison removed with the legacy credit engine — always zeroed. assertThat(row.getLegacyCreditsCharged()).isZero(); assertThat(row.getDiffPct()).isZero(); // PAYG analytics axis: billing_category + job_source are copied from the context so the diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java index 595ce0ecd..321636191 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java @@ -26,9 +26,8 @@ import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse; import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest; /** - * Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code - * CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's - * error mapping (4xx for validation, 404 for missing rows). + * Tests {@link PricingPolicyAdminController} as a plain Java unit (no MockMvc layer). Covers happy + * paths and the controller's error mapping (4xx for validation, 404 for missing rows). */ @ExtendWith(MockitoExtension.class) class PricingPolicyAdminControllerTest { diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java index 42238502f..0069b9742 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java @@ -46,7 +46,6 @@ class SupabaseAuthenticationFilterTest { @Mock private TeamService teamService; @Mock private UserService userService; @Mock private SupabaseUserService supabaseUserService; - @Mock private stirling.software.saas.service.CreditService creditService; @Mock private stirling.software.saas.service.SaasTeamService saasTeamService; @Mock private JwtDecoder jwtDecoder; @@ -60,12 +59,7 @@ class SupabaseAuthenticationFilterTest { SecurityContextHolder.clearContext(); filter = new SupabaseAuthenticationFilter( - teamService, - userService, - supabaseUserService, - creditService, - saasTeamService, - jwtDecoder); + teamService, userService, supabaseUserService, saasTeamService, jwtDecoder); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); chain = new MockFilterChain(); diff --git a/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java b/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java deleted file mode 100644 index f7d59b7b2..000000000 --- a/app/saas/src/test/java/stirling/software/saas/service/CreditResetSchedulerTest.java +++ /dev/null @@ -1,219 +0,0 @@ -package stirling.software.saas.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.within; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.temporal.ChronoUnit; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InOrder; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import stirling.software.saas.config.CreditsProperties; - -/** - * Unit tests for {@link CreditResetScheduler}. - * - *

    The scheduler has two {@code @Scheduled} entry points that are invoked directly (no Spring - * context, no real cron firing). {@code resetCycleCredits()} parses the configured zone, builds a - * {@code LocalDateTime} in that zone, and delegates to {@link CreditService} for users then teams, - * swallowing any exception. {@code performDailyMaintenance()} is a pure no-op log step that must - * never touch {@link CreditService}. - */ -@ExtendWith(MockitoExtension.class) -class CreditResetSchedulerTest { - - @Mock private CreditService creditService; - - /** Real CreditsProperties (a simple @Data POJO) configured per test via its nested Reset. */ - private static CreditsProperties props(String cron, String zone) { - CreditsProperties p = new CreditsProperties(); - p.getReset().setCron(cron); - p.getReset().setZone(zone); - return p; - } - - private CreditResetScheduler scheduler(CreditsProperties props) { - return new CreditResetScheduler(creditService, props); - } - - @Nested - @DisplayName("resetCycleCredits - happy path") - class ResetHappyPath { - - @Test - @DisplayName("resets users then teams exactly once each") - void resetsUsersThenTeamsOnce() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - - s.resetCycleCredits(); - - verify(creditService, times(1)).resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - verify(creditService, times(1)).resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - } - - @Test - @DisplayName("users are reset strictly before teams") - void usersResetBeforeTeams() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - - s.resetCycleCredits(); - - InOrder order = inOrder(creditService); - order.verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - order.verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - order.verifyNoMoreInteractions(); - } - - @Test - @DisplayName("passes a non-null reset time and uses the same instant for users and teams") - void passesSameNonNullResetTimeToBoth() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - - s.resetCycleCredits(); - - ArgumentCaptor userTime = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor teamTime = ArgumentCaptor.forClass(LocalDateTime.class); - verify(creditService).resetCycleCreditsForAllUsers(userTime.capture()); - verify(creditService).resetCycleCreditsForAllTeams(teamTime.capture()); - - assertThat(userTime.getValue()).isNotNull(); - assertThat(teamTime.getValue()).isNotNull(); - // The very same LocalDateTime instance is threaded through both calls. - assertThat(teamTime.getValue()).isSameAs(userTime.getValue()); - } - - @Test - @DisplayName("reset time is computed in the configured zone (~now)") - void resetTimeMatchesConfiguredZone() { - String zone = "UTC"; - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone)); - LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone)); - - s.resetCycleCredits(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class); - verify(creditService).resetCycleCreditsForAllUsers(captor.capture()); - // Wall-clock now in the same zone; allow generous slack to avoid flakiness. - assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS)); - } - - @Test - @DisplayName("a non-UTC zone is honored when building the reset time") - void nonUtcZoneHonored() { - String zone = "America/New_York"; - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone)); - LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone)); - - s.resetCycleCredits(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class); - verify(creditService).resetCycleCreditsForAllUsers(captor.capture()); - assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS)); - } - } - - @Nested - @DisplayName("resetCycleCredits - error handling (exceptions are swallowed)") - class ResetErrorHandling { - - @Test - @DisplayName("user-reset failure is swallowed and short-circuits the team reset") - void userResetThrows_swallowedAndTeamsSkipped() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - doThrow(new RuntimeException("user reset boom")) - .when(creditService) - .resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - - // Must not propagate. - s.resetCycleCredits(); - - verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - // Exception thrown before the team call is reached. - verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - } - - @Test - @DisplayName("team-reset failure is swallowed after users already reset") - void teamResetThrows_swallowedAfterUsersReset() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - doThrow(new RuntimeException("team reset boom")) - .when(creditService) - .resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - - // Must not propagate. - s.resetCycleCredits(); - - verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - } - - @Test - @DisplayName("an invalid zone string is swallowed and no reset work is attempted") - void invalidZone_swallowedNoResets() { - // ZoneId.of on a bad id throws DateTimeException before any delegation occurs. - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "Not/AZone")); - - // Must not propagate. - s.resetCycleCredits(); - - verifyNoInteractions(creditService); - } - - @Test - @DisplayName("any RuntimeException subtype from the user reset is swallowed") - void userResetRuntimeExceptionTolerated() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - // Production catches (Exception e); any RuntimeException is absorbed. - doThrow(new IllegalStateException("transient")) - .when(creditService) - .resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - - s.resetCycleCredits(); - - verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class)); - verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class)); - } - } - - @Nested - @DisplayName("performDailyMaintenance") - class DailyMaintenance { - - @Test - @DisplayName("runs cleanly and never touches the credit service") - void noOp_doesNotTouchCreditService() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - - s.performDailyMaintenance(); - - verifyNoInteractions(creditService); - } - - @Test - @DisplayName("is idempotent across repeated invocations") - void repeatedInvocationsRemainNoOp() { - CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC")); - - s.performDailyMaintenance(); - s.performDailyMaintenance(); - s.performDailyMaintenance(); - - verifyNoInteractions(creditService); - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java deleted file mode 100644 index eb7733f5f..000000000 --- a/app/saas/src/test/java/stirling/software/saas/service/CreditServiceTest.java +++ /dev/null @@ -1,1291 +0,0 @@ -package stirling.software.saas.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; -import stirling.software.proprietary.security.model.Authority; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.billing.service.StripeUsageReportingService; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.repository.TeamCreditRepository; -import stirling.software.saas.repository.UserCreditRepository; -import stirling.software.saas.service.CreditService.CreditSummary; - -/** - * Unit tests for {@link CreditService}. - * - *

    The service is built with mocked repositories/collaborators and a real {@link - * SimpleMeterRegistry} (Micrometer counters/gauges are constructed in the ctor and need a live - * registry). A real {@link CreditsProperties} carries the default role allocations and the default - * monthly-reset cron/zone so the private allocation + scheduled-reset arithmetic is exercised with - * production defaults. Credit math is pure arithmetic, so exact numbers are asserted. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class CreditServiceTest { - - @Mock private UserCreditRepository userCreditRepository; - @Mock private TeamCreditRepository teamCreditRepository; - @Mock private UserRepository userRepository; - @Mock private UserService userService; - @Mock private TeamCreditService teamCreditService; - @Mock private StripeUsageReportingService stripeUsageReportingService; - @Mock private SaasUserExtensionService saasUserExtensionService; - @Mock private SaasTeamExtensionService saasTeamExtensionService; - - private CreditsProperties creditsProperties; - private MeterRegistry meterRegistry; - private CreditService service; - - private static final String VALID_SUPABASE = "11111111-1111-1111-1111-111111111111"; - - @BeforeEach - void setUp() { - // Defensive: another test in the JVM may have left a synchronization registered. Clearing - // guarantees the no-active-tx path runs the Stripe report synchronously in these tests. - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clear(); - } - creditsProperties = new CreditsProperties(); - meterRegistry = new SimpleMeterRegistry(); - service = - new CreditService( - userCreditRepository, - teamCreditRepository, - userRepository, - userService, - creditsProperties, - teamCreditService, - stripeUsageReportingService, - saasUserExtensionService, - saasTeamExtensionService, - meterRegistry); - } - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - // ---------------------------------------------------------------------------------------- - // Fixtures - // ---------------------------------------------------------------------------------------- - - private static User user(Long id, String username, String... roles) { - User u = new User(); - u.setId(id); - u.setUsername(username); - for (String role : roles) { - new Authority(role, u); // constructor adds itself to u.getAuthorities() - } - return u; - } - - /** A UserCredit whose last reset is "now", so getOrCreateUserCredits never triggers a reset. */ - private static UserCredit userCredit(User u, int cycleRemaining, int boughtRemaining) { - UserCredit c = new UserCredit(u); - c.setCycleCreditsRemaining(cycleRemaining); - c.setCycleCreditsAllocated(cycleRemaining); - c.setBoughtCreditsRemaining(boughtRemaining); - c.setTotalBoughtCredits(boughtRemaining); - c.setLastCycleResetAt(LocalDateTime.now()); - return c; - } - - // ---------------------------------------------------------------------------------------- - // Lookups by API key / Supabase ID / user ID - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Lookups") - class Lookups { - - @Test - @DisplayName("getUserCreditsByApiKey delegates to repository") - void byApiKey_delegates() { - User u = user(1L, "alice"); - UserCredit c = userCredit(u, 5, 0); - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c)); - - assertThat(service.getUserCreditsByApiKey("key")).containsSame(c); - } - - @Test - @DisplayName("getUserCreditsBySupabaseId parses a valid UUID and queries the repo") - void bySupabaseId_validUuid() { - User u = user(1L, "alice"); - UserCredit c = userCredit(u, 5, 0); - when(userCreditRepository.findBySupabaseId(UUID.fromString(VALID_SUPABASE))) - .thenReturn(Optional.of(c)); - - assertThat(service.getUserCreditsBySupabaseId(VALID_SUPABASE)).containsSame(c); - } - - @Test - @DisplayName("getUserCreditsBySupabaseId returns empty on malformed UUID (no repo call)") - void bySupabaseId_invalidUuid() { - assertThat(service.getUserCreditsBySupabaseId("not-a-uuid")).isEmpty(); - verify(userCreditRepository, never()).findBySupabaseId(any()); - } - - @Test - @DisplayName("getUserCreditsByUserId delegates to repository") - void byUserId_delegates() { - User u = user(7L, "bob"); - UserCredit c = userCredit(u, 3, 1); - when(userCreditRepository.findByUserId(7L)).thenReturn(Optional.of(c)); - - assertThat(service.getUserCreditsByUserId(7L)).containsSame(c); - } - - @Test - @DisplayName("getUserBySupabaseId delegates to UserService") - void userBySupabaseId_delegates() { - UUID id = UUID.fromString(VALID_SUPABASE); - User u = user(1L, "alice"); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - - assertThat(service.getUserBySupabaseId(id)).containsSame(u); - } - } - - // ---------------------------------------------------------------------------------------- - // getOrCreateUserCredits — existing (reset / no reset) and creation - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("getOrCreateUserCredits") - class GetOrCreate { - - @Test - @DisplayName("returns the existing row unchanged when no cycle reset is due") - void existing_noResetDue_returnsAsIs() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit existing = userCredit(u, 42, 7); // lastReset = now -> not due - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(existing)); - - UserCredit result = service.getOrCreateUserCredits(u); - - assertThat(result).isSameAs(existing); - assertThat(result.getCycleCreditsRemaining()).isEqualTo(42); - verify(userCreditRepository, never()).save(any()); - } - - @Test - @DisplayName("resets cycle credits to the role allocation when reset is overdue") - void existing_resetDue_resetsToAllocation() { - User u = user(1L, "alice", "ROLE_USER"); // default ROLE_USER -> 50 - UserCredit existing = userCredit(u, 3, 9); - // Force "reset due": last reset far in the past, before the most-recent scheduled - // reset. - existing.setLastCycleResetAt(LocalDateTime.now().minusYears(1)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(existing)); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit result = service.getOrCreateUserCredits(u); - - // Cycle reset to ROLE_USER allocation; bought pool is untouched by resetCycleCredits. - assertThat(result.getCycleCreditsRemaining()).isEqualTo(50); - assertThat(result.getCycleCreditsAllocated()).isEqualTo(50); - assertThat(result.getBoughtCreditsRemaining()).isEqualTo(9); - verify(userCreditRepository).save(existing); - } - - @Test - @DisplayName("creates a new credit row allocated for the user's role when none exists") - void missing_createsWithAllocation() { - User u = user(1L, "pro", "ROLE_PRO_USER"); // 500 - when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty()); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit result = service.getOrCreateUserCredits(u); - - assertThat(result.getCycleCreditsRemaining()).isEqualTo(500); - assertThat(result.getCycleCreditsAllocated()).isEqualTo(500); - assertThat(result.getLastCycleResetAt()).isNotNull(); - verify(userCreditRepository).save(any(UserCredit.class)); - } - } - - // ---------------------------------------------------------------------------------------- - // Role-based cycle allocation (exercised via initializeCreditsForUser / getOrCreate) - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Cycle allocation by role") - class Allocation { - - @Test - @DisplayName("admin with adminUnlimited gets Integer.MAX_VALUE") - void admin_unlimited() { - User u = user(1L, "admin", "ROLE_ADMIN"); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(Integer.MAX_VALUE); - } - - @Test - @DisplayName("admin is bounded by allocation map when adminUnlimited is disabled") - void admin_bounded_whenUnlimitedDisabled() { - creditsProperties.getCycle().setAdminUnlimited(false); - User u = user(1L, "admin", "ROLE_ADMIN"); // map has ROLE_ADMIN -> 1000 - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(1000); - } - - @Test - @DisplayName("internal API user always gets unlimited credits") - void internalApi_unlimited() { - User u = user(1L, "internal", "ROLE_INTERNAL_API_USER"); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(Integer.MAX_VALUE); - } - - @Test - @DisplayName("pro user gets the configured ROLE_PRO_USER allocation") - void pro_getsConfiguredAmount() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(500); - } - - @Test - @DisplayName("unknown role falls back to ROLE_USER default allocation") - void unknownRole_defaultsToUser() { - User u = user(1L, "mystery", "ROLE_SOMETHING_ELSE"); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(50); - } - - @Test - @DisplayName("user with no roles gets zero allocation (rolesString empty, default path)") - void noRoles_defaultAllocation() { - // getRolesAsString() returns "" (not null) for a user with no authorities, so the - // null-guard is skipped and the ROLE_USER default applies. - User u = user(1L, "empty"); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - UserCredit c = service.initializeCreditsForUser(u); - - assertThat(c.getCycleCreditsAllocated()).isEqualTo(50); - } - } - - // ---------------------------------------------------------------------------------------- - // hasCreditsAvailable (by API key) — present / lazy-create / no user - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("hasCreditsAvailable by API key") - class HasCreditsApiKey { - - @Test - @DisplayName("true when an existing row has a positive balance") - void existingRow_positive_true() { - User u = user(1L, "alice"); - when(userCreditRepository.findByUserApiKey("key")) - .thenReturn(Optional.of(userCredit(u, 5, 0))); - - assertThat(service.hasCreditsAvailable("key")).isTrue(); - } - - @Test - @DisplayName("false when an existing row is fully depleted") - void existingRow_zero_false() { - User u = user(1L, "alice"); - when(userCreditRepository.findByUserApiKey("key")) - .thenReturn(Optional.of(userCredit(u, 0, 0))); - - assertThat(service.hasCreditsAvailable("key")).isFalse(); - } - - @Test - @DisplayName("lazy-creates credits for a user that has no row yet") - void noRow_userExists_lazyCreates() { - User u = user(1L, "alice", "ROLE_PRO_USER"); - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty()); - when(userRepository.findByApiKey("key")).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty()); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - assertThat(service.hasCreditsAvailable("key")).isTrue(); // 500 allocated - verify(userCreditRepository).save(any(UserCredit.class)); - } - - @Test - @DisplayName("false when no user is found for the API key") - void noUser_false() { - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty()); - when(userRepository.findByApiKey("key")).thenReturn(Optional.empty()); - - assertThat(service.hasCreditsAvailable("key")).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // consumeCredit (by API key) — atomic update success/failure - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("consumeCredit by API key") - class ConsumeApiKey { - - @Test - @DisplayName("returns true when the atomic update affects exactly one row") - void oneRowUpdated_true() { - when(userCreditRepository.consumeCredit("key", 3)).thenReturn(1); - - assertThat(service.consumeCredit("key", 3)).isTrue(); - } - - @Test - @DisplayName("returns false when no row is updated (insufficient credits)") - void zeroRowsUpdated_false() { - when(userCreditRepository.consumeCredit("key", 3)).thenReturn(0); - - assertThat(service.consumeCredit("key", 3)).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // consumeCreditBySupabaseId — prepaid, metered (full free / overage), errors - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("consumeCreditBySupabaseId") - class ConsumeBySupabase { - - @Test - @DisplayName("invalid UUID returns false without touching the user service") - void invalidUuid_false() { - assertThat(service.consumeCreditBySupabaseId("nope", 1)).isFalse(); - verify(userService, never()).findBySupabaseId(any()); - } - - @Test - @DisplayName("missing user returns false") - void missingUser_false() { - when(userService.findBySupabaseId(any())).thenReturn(Optional.empty()); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 1)).isFalse(); - } - - @Test - @DisplayName("prepaid (non-metered) user: one-row update returns true") - void prepaid_success() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false); - when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(1); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isTrue(); - } - - @Test - @DisplayName("prepaid user: zero-row update returns false (insufficient credits)") - void prepaid_insufficient_false() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false); - when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(0); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isFalse(); - } - - @Test - @DisplayName("metered user fully covered by free tier consumes from free tier only") - void metered_fullyFree_noStripe() { - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 10, 0))); - when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(1); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isTrue(); - - // Wholly covered by the free tier -> no Stripe report at all. - verify(stripeUsageReportingService, never()) - .reportUsageToStripe(anyString(), anyInt(), anyString()); - } - - @Test - @DisplayName( - "metered user: overage consumes free credits then reports the overage to Stripe") - void metered_overage_reportsOverage() { - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - // 3 free credits remain; need 10 -> 3 free + 7 overage. - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 3, 0))); - when(userCreditRepository.consumeCreditBySupabaseId(id, 3)).thenReturn(1); - when(stripeUsageReportingService.generateIdempotencyKey( - eq(VALID_SUPABASE), eq(7), any())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 7, "idem-key")) - .thenReturn(true); - - boolean result = service.consumeCreditBySupabaseId(VALID_SUPABASE, 10); - - assertThat(result).isTrue(); - // Free portion debited in-tx; overage (7) metered to Stripe (sync, no active tx). - verify(userCreditRepository).consumeCreditBySupabaseId(id, 3); - verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 7, "idem-key"); - } - - @Test - @DisplayName("metered user: full overage (no free credits) reports the entire amount") - void metered_fullOverage_reportsAll() { - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 0, 0))); - when(stripeUsageReportingService.generateIdempotencyKey( - eq(VALID_SUPABASE), eq(5), any())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key")) - .thenReturn(true); - - boolean result = service.consumeCreditBySupabaseId(VALID_SUPABASE, 5); - - assertThat(result).isTrue(); - // No free credits to debit, so no DB consume; whole 5 metered. - verify(userCreditRepository, never()).consumeCreditBySupabaseId(any(), anyInt()); - verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 5, "idem-key"); - } - - @Test - @DisplayName("metered overage still returns true even when the Stripe report fails") - void metered_overage_stripeFails_stillTrue() { - // The DB debit already committed; a Stripe failure is logged + counted but the call - // succeeds so the user is not double-charged for an unbilled-but-owed overage. - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 0, 0))); - when(stripeUsageReportingService.generateIdempotencyKey( - eq(VALID_SUPABASE), eq(5), any())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key")) - .thenReturn(false); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 5)).isTrue(); - } - - @Test - @DisplayName("metered free-tier debit lost a concurrent race -> returns false") - void metered_freeTierRaceLost_false() { - // In-memory check said free tier covers it, but the atomic UPDATE found 0 rows. - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(userCredit(u, 10, 0))); - when(userCreditRepository.consumeCreditBySupabaseId(id, 4)).thenReturn(0); - - assertThat(service.consumeCreditBySupabaseId(VALID_SUPABASE, 4)).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // hasCreditsAvailableBySupabaseId - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("hasCreditsAvailableBySupabaseId") - class HasCreditsSupabase { - - @Test - @DisplayName("true for an existing row with a positive balance") - void existing_positive_true() { - User u = user(1L, "alice"); - when(userCreditRepository.findBySupabaseId(UUID.fromString(VALID_SUPABASE))) - .thenReturn(Optional.of(userCredit(u, 2, 0))); - - assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isTrue(); - } - - @Test - @DisplayName("lazy-creates a row for a user without one") - void noRow_lazyCreates() { - User u = user(1L, "alice", "ROLE_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty()); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.empty()); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isTrue(); - } - - @Test - @DisplayName("false when the UUID is malformed") - void invalidUuid_false() { - assertThat(service.hasCreditsAvailableBySupabaseId("bad")).isFalse(); - } - - @Test - @DisplayName("false when no user exists for a well-formed UUID") - void noUser_false() { - UUID id = UUID.fromString(VALID_SUPABASE); - when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty()); - when(userService.findBySupabaseId(id)).thenReturn(Optional.empty()); - - assertThat(service.hasCreditsAvailableBySupabaseId(VALID_SUPABASE)).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // Authentication-based helpers - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Authentication helpers") - class AuthHelpers { - - @Test - @DisplayName("getUserCreditsFromAuthentication returns empty when no auth is present") - void noAuth_empty() { - SecurityContextHolder.clearContext(); - assertThat(service.getUserCreditsFromAuthentication()).isEmpty(); - } - - @Test - @DisplayName("API-key auth resolves credits by the principal user's id") - void apiKeyAuth_resolvesById() { - User u = user(9L, "alice"); - UserCredit c = userCredit(u, 3, 0); - ApiKeyAuthenticationToken auth = - new ApiKeyAuthenticationToken( - u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContextHolder.getContext().setAuthentication(auth); - when(userCreditRepository.findByUserId(9L)).thenReturn(Optional.of(c)); - - assertThat(service.getUserCreditsFromAuthentication()).containsSame(c); - } - - @Test - @DisplayName("JWT/session (UsernamePassword) auth resolves credits by the principal id") - void jwtAuth_resolvesById() { - User u = user(9L, "alice"); - UserCredit c = userCredit(u, 3, 0); - // 3-arg ctor -> authenticated principal. - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken( - u, "pw", List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContextHolder.getContext().setAuthentication(auth); - when(userCreditRepository.findByUserId(9L)).thenReturn(Optional.of(c)); - - assertThat(service.getUserCreditsFromAuthentication()).containsSame(c); - } - - @Test - @DisplayName("isApiKeyAuthenticated true only for an ApiKeyAuthenticationToken") - void isApiKeyAuthenticated() { - User u = user(9L, "alice"); - SecurityContextHolder.getContext() - .setAuthentication( - new ApiKeyAuthenticationToken( - u, "sk", List.of(new SimpleGrantedAuthority("ROLE_USER")))); - assertThat(service.isApiKeyAuthenticated()).isTrue(); - assertThat(service.isJwtAuthenticated()).isFalse(); - } - - @Test - @DisplayName("isJwtAuthenticated true only for a UsernamePasswordAuthenticationToken") - void isJwtAuthenticated() { - User u = user(9L, "alice"); - SecurityContextHolder.getContext() - .setAuthentication( - new UsernamePasswordAuthenticationToken( - u, "pw", List.of(new SimpleGrantedAuthority("ROLE_USER")))); - assertThat(service.isJwtAuthenticated()).isTrue(); - assertThat(service.isApiKeyAuthenticated()).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // Admin credit mutations by username - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Credit mutations by username") - class MutationsByUsername { - - @Test - @DisplayName("addBoughtCredits adds to the bought pool and persists") - void addBought_addsAndSaves() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit c = userCredit(u, 10, 5); - when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - service.addBoughtCredits("alice", 20); - - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(25); - assertThat(c.getTotalBoughtCredits()).isEqualTo(25); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("addBoughtCredits throws when the user does not exist") - void addBought_missingUser_throws() { - when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.addBoughtCredits("ghost", 5)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("User not found"); - } - - @Test - @DisplayName("setBoughtCredits overwrites both remaining and total bought") - void setBought_overwrites() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit c = userCredit(u, 10, 99); - when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - service.setBoughtCredits("alice", 7); - - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(7); - assertThat(c.getTotalBoughtCredits()).isEqualTo(7); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("setCycleCredits overwrites only the cycle remaining pool") - void setCycle_overwritesCycleOnly() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit c = userCredit(u, 10, 8); - when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - service.setCycleCredits("alice", 3); - - assertThat(c.getCycleCreditsRemaining()).isEqualTo(3); - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(8); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("setCycleCredits throws when the user does not exist") - void setCycle_missingUser_throws() { - when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.setCycleCredits("ghost", 5)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - // ---------------------------------------------------------------------------------------- - // Admin credit mutations by Supabase ID - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Credit mutations by Supabase ID") - class MutationsBySupabase { - - @Test - @DisplayName("addBoughtCreditsBySupabaseId adds to the bought pool") - void addBought_adds() { - User u = user(1L, "alice", "ROLE_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - UserCredit c = userCredit(u, 10, 5); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - service.addBoughtCreditsBySupabaseId(VALID_SUPABASE, 15); - - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(20); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("setCycleCreditsBySupabaseId overwrites the cycle remaining pool") - void setCycle_overwrites() { - User u = user(1L, "alice", "ROLE_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - UserCredit c = userCredit(u, 10, 5); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - service.setCycleCreditsBySupabaseId(VALID_SUPABASE, 2); - - assertThat(c.getCycleCreditsRemaining()).isEqualTo(2); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("mutation by Supabase ID throws when the user is missing") - void missingUser_throws() { - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.setBoughtCreditsBySupabaseId(VALID_SUPABASE, 5)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("User not found"); - } - - @Test - @DisplayName("mutation by Supabase ID throws on a malformed UUID") - void invalidUuid_throws() { - assertThatThrownBy(() -> service.setBoughtCreditsBySupabaseId("bad-uuid", 5)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - // ---------------------------------------------------------------------------------------- - // Cycle resets for all users / teams - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Bulk cycle resets") - class BulkResets { - - @Test - @DisplayName("resetCycleCreditsForAllUsers resets each candidate to its role allocation") - void resetUsers_perRoleAllocation() { - LocalDateTime cutoff = LocalDateTime.now(); - User proUser = user(1L, "pro", "ROLE_PRO_USER"); // 500 - User freeUser = user(2L, "free", "ROLE_USER"); // 50 - UserCredit proCredit = userCredit(proUser, 1, 0); - UserCredit freeCredit = userCredit(freeUser, 1, 0); - when(userCreditRepository.findCreditsNeedingCycleReset(cutoff)) - .thenReturn(List.of(proCredit, freeCredit)); - - service.resetCycleCreditsForAllUsers(cutoff); - - assertThat(proCredit.getCycleCreditsRemaining()).isEqualTo(500); - assertThat(proCredit.getLastCycleResetAt()).isEqualTo(cutoff); - assertThat(freeCredit.getCycleCreditsRemaining()).isEqualTo(50); - verify(userCreditRepository).save(proCredit); - verify(userCreditRepository).save(freeCredit); - } - - @Test - @DisplayName( - "resetCycleCreditsForAllUsers no-arg overload uses 'now' and saves nothing when empty") - void resetUsers_noArg_emptyList() { - when(userCreditRepository.findCreditsNeedingCycleReset(any(LocalDateTime.class))) - .thenReturn(List.of()); - - service.resetCycleCreditsForAllUsers(); - - verify(userCreditRepository, never()).save(any()); - } - - @Test - @DisplayName("resetCycleCreditsForAllTeams resets each team to the fixed PRO allocation") - void resetTeams_fixedProAllocation() { - LocalDateTime cutoff = LocalDateTime.now(); - Team team = new Team(); - team.setId(100L); - TeamCredit tc = new TeamCredit(team); - tc.setCycleCreditsRemaining(1); - when(teamCreditRepository.findCreditsNeedingCycleReset(cutoff)).thenReturn(List.of(tc)); - - service.resetCycleCreditsForAllTeams(cutoff); - - // Fixed PRO amount from the allocation map (ROLE_PRO_USER -> 500). - assertThat(tc.getCycleCreditsRemaining()).isEqualTo(500); - assertThat(tc.getCycleCreditsAllocated()).isEqualTo(500); - assertThat(tc.getLastCycleResetAt()).isEqualTo(cutoff); - verify(teamCreditRepository).save(tc); - } - } - - // ---------------------------------------------------------------------------------------- - // Credit summaries - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Credit summaries") - class Summaries { - - @Test - @DisplayName("getCreditSummaryByApiKey returns an empty summary when no row exists") - void byApiKey_missing_empty() { - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.empty()); - - CreditSummary summary = service.getCreditSummaryByApiKey("key"); - - assertThat(summary.totalAvailableCredits).isZero(); - assertThat(summary.unlimited).isFalse(); - } - - @Test - @DisplayName("getCreditSummaryByApiKey maps the row fields and flags unlimited correctly") - void byApiKey_mapsFields() { - User u = user(1L, "alice"); - UserCredit c = userCredit(u, 30, 12); - c.setCycleCreditsAllocated(50); - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c)); - - CreditSummary summary = service.getCreditSummaryByApiKey("key"); - - assertThat(summary.cycleCreditsRemaining).isEqualTo(30); - assertThat(summary.cycleCreditsAllocated).isEqualTo(50); - assertThat(summary.boughtCreditsRemaining).isEqualTo(12); - assertThat(summary.totalAvailableCredits).isEqualTo(42); - assertThat(summary.unlimited).isFalse(); - } - - @Test - @DisplayName("getCreditSummaryByApiKey flags unlimited when cycle allocation is MAX_VALUE") - void byApiKey_unlimited() { - User u = user(1L, "admin"); - UserCredit c = userCredit(u, 5, 0); - c.setCycleCreditsAllocated(Integer.MAX_VALUE); - when(userCreditRepository.findByUserApiKey("key")).thenReturn(Optional.of(c)); - - assertThat(service.getCreditSummaryByApiKey("key").unlimited).isTrue(); - } - - @Test - @DisplayName("getCreditSummary(username) returns an empty summary for an unknown username") - void byUsername_missing_empty() { - when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); - - assertThat(service.getCreditSummary("ghost").totalAvailableCredits).isZero(); - } - - @Test - @DisplayName("getCreditSummary(username) maps the (lazily resolved) credit row") - void byUsername_maps() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit c = userCredit(u, 20, 5); - when(userRepository.findByUsername("alice")).thenReturn(Optional.of(u)); - when(userCreditRepository.findByUser(u)).thenReturn(Optional.of(c)); - - CreditSummary summary = service.getCreditSummary("alice"); - - assertThat(summary.cycleCreditsRemaining).isEqualTo(20); - assertThat(summary.totalAvailableCredits).isEqualTo(25); - } - - @Test - @DisplayName("getCreditSummaryBySupabaseId returns empty for a malformed UUID") - void bySupabase_invalidUuid_empty() { - assertThat(service.getCreditSummaryBySupabaseId("bad").totalAvailableCredits).isZero(); - } - - @Test - @DisplayName("getCreditSummaryBySupabaseId returns empty when no user is found") - void bySupabase_noUser_empty() { - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.empty()); - - assertThat(service.getCreditSummaryBySupabaseId(VALID_SUPABASE).totalAvailableCredits) - .isZero(); - } - - @Test - @DisplayName("non-personal team member gets the shared team pool, never personal credits") - void bySupabase_teamMember_returnsTeamPool() { - User u = user(1L, "teamie", "ROLE_USER"); - Team team = new Team(); - team.setId(100L); - u.setTeam(team); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - - TeamCredit tc = new TeamCredit(team); - tc.setCycleCreditsRemaining(300); - tc.setCycleCreditsAllocated(500); - tc.setBoughtCreditsRemaining(50); - tc.setTotalBoughtCredits(50); - when(teamCreditService.getTeamCredits(100L)).thenReturn(Optional.of(tc)); - - CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE); - - assertThat(summary.cycleCreditsRemaining).isEqualTo(300); - assertThat(summary.totalAvailableCredits).isEqualTo(350); - assertThat(summary.unlimited).isFalse(); - // Team path: personal credits must not be consulted. - verify(userCreditRepository, never()).findBySupabaseId(any()); - } - - @Test - @DisplayName("non-personal team with no credit record returns an empty summary") - void bySupabase_teamMember_noTeamCredit_empty() { - User u = user(1L, "teamie", "ROLE_USER"); - Team team = new Team(); - team.setId(100L); - u.setTeam(team); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(false); - when(teamCreditService.getTeamCredits(100L)).thenReturn(Optional.empty()); - - assertThat(service.getCreditSummaryBySupabaseId(VALID_SUPABASE).totalAvailableCredits) - .isZero(); - } - - @Test - @DisplayName("limited API team member uses personal credits, not the team pool") - void bySupabase_limitedApiUser_usesPersonal() { - User u = user(1L, "limited", "ROLE_LIMITED_API_USER"); - Team team = new Team(); - team.setId(100L); - u.setTeam(team); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(userCreditRepository.findBySupabaseId(id)) - .thenReturn(Optional.of(userCredit(u, 8, 0))); - - CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE); - - assertThat(summary.cycleCreditsRemaining).isEqualTo(8); - // Limited API users never consult the team pool. - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("personal-team user returns personal credits") - void bySupabase_personalTeam_returnsPersonal() { - User u = user(1L, "solo", "ROLE_USER"); - Team team = new Team(); - team.setId(100L); - u.setTeam(team); - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(saasTeamExtensionService.isPersonal(team)).thenReturn(true); - when(userCreditRepository.findBySupabaseId(id)) - .thenReturn(Optional.of(userCredit(u, 15, 5))); - - CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE); - - assertThat(summary.totalAvailableCredits).isEqualTo(20); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("teamless personal user with no row triggers lazy initialization") - void bySupabase_noRow_lazyInit() { - User u = user(1L, "fresh", "ROLE_PRO_USER"); // 500 - UUID id = UUID.fromString(VALID_SUPABASE); - when(userService.findBySupabaseId(id)).thenReturn(Optional.of(u)); - when(userCreditRepository.findBySupabaseId(id)).thenReturn(Optional.empty()); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - CreditSummary summary = service.getCreditSummaryBySupabaseId(VALID_SUPABASE); - - assertThat(summary.cycleCreditsAllocated).isEqualTo(500); - verify(userCreditRepository).save(any(UserCredit.class)); - } - } - - // ---------------------------------------------------------------------------------------- - // Role-change refresh / allocation reset - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("Role-change credit refresh") - class RoleChange { - - @Test - @DisplayName( - "refreshCreditsAfterRoleChange fully resets cycle credits to the new allocation") - void refresh_resetsToNewAllocation() { - User u = user(1L, "upgraded", "ROLE_PRO_USER"); // new allocation 500 - UserCredit c = userCredit(u, 3, 40); // had only 3 cycle, 40 bought - when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.of(c)); - - service.refreshCreditsAfterRoleChange(u); - - assertThat(c.getCycleCreditsRemaining()).isEqualTo(500); - assertThat(c.getCycleCreditsAllocated()).isEqualTo(500); - // Bought pool is preserved across a role change. - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(40); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("refreshCreditsAfterRoleChange initializes credits when none exist") - void refresh_noRow_initializes() { - User u = user(1L, "new", "ROLE_USER"); - when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.empty()); - when(userCreditRepository.save(any(UserCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - service.refreshCreditsAfterRoleChange(u); - - ArgumentCaptor captor = ArgumentCaptor.forClass(UserCredit.class); - verify(userCreditRepository).save(captor.capture()); - assertThat(captor.getValue().getCycleCreditsAllocated()).isEqualTo(50); - } - - @Test - @DisplayName("resetCycleAllocationForRoleChange performs a full reset to the given amount") - void resetAllocation_fullReset() { - User u = user(1L, "alice", "ROLE_USER"); - UserCredit c = userCredit(u, 10, 25); - when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.of(c)); - - service.resetCycleAllocationForRoleChange(1L, 200); - - assertThat(c.getCycleCreditsRemaining()).isEqualTo(200); - assertThat(c.getCycleCreditsAllocated()).isEqualTo(200); - assertThat(c.getBoughtCreditsRemaining()).isEqualTo(25); - verify(userCreditRepository).save(c); - } - - @Test - @DisplayName("resetCycleAllocationForRoleChange throws when the user has no credit row") - void resetAllocation_missingRow_throws() { - when(userCreditRepository.findByUserId(1L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.resetCycleAllocationForRoleChange(1L, 200)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("User credits not found"); - } - } - - // ---------------------------------------------------------------------------------------- - // consumeCreditWithWaterfall — the explicit Pro-billing waterfall - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("consumeCreditWithWaterfall") - class Waterfall { - - @Test - @DisplayName("internal backend API user is unlimited; no Supabase ID needed") - void internalApiUser_unlimited() { - User u = user(1L, "backend", "STIRLING-PDF-BACKEND-API-USER"); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 100, true); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("INTERNAL_API_UNLIMITED"); - verify(userCreditRepository, never()).hasCycleCredits(any(), anyInt()); - } - - @Test - @DisplayName("user without a Supabase ID fails") - void noSupabaseId_failure() { - User u = user(1L, "noid", "ROLE_PRO_USER"); - u.setSupabaseId(null); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).contains("no Supabase ID"); - } - - @Test - @DisplayName("step 1: consumes from cycle credits when available") - void cycleCredits_consumed() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.TRUE); - when(userCreditRepository.consumeCycleCredits(id, 5)).thenReturn(1); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("CYCLE_CREDITS"); - verify(userCreditRepository, never()).hasBoughtCredits(any(), anyInt()); - } - - @Test - @DisplayName("step 2: falls through to bought credits when cycle credits are insufficient") - void boughtCredits_consumed() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE); - when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.TRUE); - when(userCreditRepository.consumeBoughtCredits(id, 5)).thenReturn(1); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("BOUGHT_CREDITS"); - } - - @Test - @DisplayName("cycle check passes but the atomic update loses a race -> falls to bought") - void cycleRaceLost_fallsToBought() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.TRUE); - when(userCreditRepository.consumeCycleCredits(id, 5)).thenReturn(0); // lost the race - when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.TRUE); - when(userCreditRepository.consumeBoughtCredits(id, 5)).thenReturn(1); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("BOUGHT_CREDITS"); - } - - @Test - @DisplayName("step 3: metered billing schedules a Stripe report and succeeds") - void metered_subscription_success() { - User u = user(1L, "meter", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE); - when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - eq(VALID_SUPABASE), eq(5), any())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe(VALID_SUPABASE, 5, "idem-key")) - .thenReturn(true); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("METERED_SUBSCRIPTION"); - // No active tx -> report runs synchronously with the full amount. - verify(stripeUsageReportingService).reportUsageToStripe(VALID_SUPABASE, 5, "idem-key"); - } - - @Test - @DisplayName( - "Pro user without metered billing is rejected with the overage-billing message") - void proWithoutMetered_rejected() { - User u = user(1L, "pro", "ROLE_PRO_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE); - when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).contains("overage billing"); - } - - @Test - @DisplayName("non-Pro user with no credit source is rejected as INSUFFICIENT_CREDITS") - void nonPro_noSource_insufficient() { - User u = user(1L, "free", "ROLE_USER"); - UUID id = UUID.fromString(VALID_SUPABASE); - u.setSupabaseId(id); - when(userCreditRepository.hasCycleCredits(id, 5)).thenReturn(Boolean.FALSE); - when(userCreditRepository.hasBoughtCredits(id, 5)).thenReturn(Boolean.FALSE); - when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(u, 5, true); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).isEqualTo("INSUFFICIENT_CREDITS"); - } - } - - // ---------------------------------------------------------------------------------------- - // CreditSummary value holder - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("CreditSummary value holder") - class SummaryHolder { - - @Test - @DisplayName("no-arg constructor zeroes everything and is not unlimited") - void emptySummary() { - CreditSummary s = new CreditSummary(); - - assertThat(s.cycleCreditsRemaining).isZero(); - assertThat(s.cycleCreditsAllocated).isZero(); - assertThat(s.boughtCreditsRemaining).isZero(); - assertThat(s.totalBoughtCredits).isZero(); - assertThat(s.totalAvailableCredits).isZero(); - assertThat(s.cycleResetDate).isNull(); - assertThat(s.lastApiUsage).isNull(); - assertThat(s.unlimited).isFalse(); - } - - @Test - @DisplayName("all-args constructor wires public fields verbatim") - void fullSummary() { - LocalDateTime reset = LocalDateTime.now(); - LocalDateTime usage = reset.plusMinutes(1); - CreditSummary s = new CreditSummary(30, 50, 12, 100, 42, reset, usage, true); - - assertThat(s.cycleCreditsRemaining).isEqualTo(30); - assertThat(s.cycleCreditsAllocated).isEqualTo(50); - assertThat(s.boughtCreditsRemaining).isEqualTo(12); - assertThat(s.totalBoughtCredits).isEqualTo(100); - assertThat(s.totalAvailableCredits).isEqualTo(42); - assertThat(s.cycleResetDate).isEqualTo(reset); - assertThat(s.lastApiUsage).isEqualTo(usage); - assertThat(s.unlimited).isTrue(); - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java b/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java index 6454dfe3b..6b86f107d 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/StripeAfterCommitOrderingTest.java @@ -11,7 +11,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; /** - * Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link + * Pins the contract {@code JobChargeService.close} relies on for its Stripe meter post: a {@link * TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on * rollback. */ diff --git a/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java deleted file mode 100644 index 1288a0bca..000000000 --- a/app/saas/src/test/java/stirling/software/saas/service/TeamCreditServiceTest.java +++ /dev/null @@ -1,612 +0,0 @@ -package stirling.software.saas.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import org.slf4j.MDC; - -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.billing.service.StripeUsageReportingService; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.CreditConsumptionResult; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamCreditRepository; -import stirling.software.saas.repository.TeamMembershipRepository; - -/** - * Unit tests for {@link TeamCreditService}. Collaborators (repositories, billing/extension - * services) are mocked; credit math is pure arithmetic so exact numbers are asserted. The waterfall - * path is exercised across every branch: pool hit, no leader, metered-billing disabled, missing - * Supabase id, Stripe report success/failure, and a thrown exception during reporting. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class TeamCreditServiceTest { - - @Mock private TeamCreditRepository teamCreditRepository; - @Mock private TeamMembershipRepository membershipRepository; - @Mock private StripeUsageReportingService stripeUsageReportingService; - @Mock private SaasUserExtensionService saasUserExtensionService; - - // Real CreditsProperties carries the production default allocations (ROLE_PRO_USER -> 500). - private final CreditsProperties creditsProperties = new CreditsProperties(); - - private TeamCreditService service; - - @BeforeEach - void setUp() { - service = - new TeamCreditService( - teamCreditRepository, - membershipRepository, - creditsProperties, - stripeUsageReportingService, - saasUserExtensionService); - } - - @AfterEach - void clearMdc() { - MDC.clear(); - } - - // ---------------------------------------------------------------------------------------- - // Fixtures - // ---------------------------------------------------------------------------------------- - - private static Team team(Long id) { - Team t = new Team(); - t.setId(id); - return t; - } - - private static User user(Long id, String username) { - User u = new User(); - u.setId(id); - u.setUsername(username); - return u; - } - - private static TeamCredit credit(int cycleRemaining, int boughtRemaining) { - TeamCredit c = new TeamCredit(); - c.setCycleCreditsRemaining(cycleRemaining); - c.setCycleCreditsAllocated(cycleRemaining); - c.setBoughtCreditsRemaining(boughtRemaining); - c.setTotalBoughtCredits(boughtRemaining); - return c; - } - - private static TeamMembership leaderMembership(User leader) { - TeamMembership m = new TeamMembership(); - m.setRole(TeamRole.LEADER); - m.setUser(leader); - return m; - } - - // ---------------------------------------------------------------------------------------- - // initializeTeamCredits - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("initializeTeamCredits") - class InitializeTeamCredits { - - @Test - @DisplayName("returns the existing row without saving when credits already exist") - void returnsExistingWithoutSaving() { - Team team = team(100L); - TeamCredit existing = credit(123, 0); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(existing)); - - TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary")); - - assertThat(result).isSameAs(existing); - verify(teamCreditRepository, never()).save(any()); - } - - @Test - @DisplayName("seeds the fixed PRO allocation (500) into both cycle fields for a new team") - void seedsFixedProAllocation() { - Team team = team(100L); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - when(teamCreditRepository.save(any(TeamCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary")); - - // 500 is the production ROLE_PRO_USER default in CreditsProperties. - assertThat(result.getCycleCreditsAllocated()).isEqualTo(500); - assertThat(result.getCycleCreditsRemaining()).isEqualTo(500); - assertThat(result.getLastCycleResetAt()).isNotNull(); - assertThat(result.getTeam()).isSameAs(team); - - ArgumentCaptor captor = ArgumentCaptor.forClass(TeamCredit.class); - verify(teamCreditRepository).save(captor.capture()); - assertThat(captor.getValue().getCycleCreditsRemaining()).isEqualTo(500); - } - - @Test - @DisplayName("honours a custom ROLE_PRO_USER allocation from properties") - void honoursCustomAllocation() { - CreditsProperties custom = new CreditsProperties(); - custom.getCycle().setAllocations(Map.of("ROLE_PRO_USER", 750)); - TeamCreditService svc = - new TeamCreditService( - teamCreditRepository, - membershipRepository, - custom, - stripeUsageReportingService, - saasUserExtensionService); - Team team = team(100L); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - when(teamCreditRepository.save(any(TeamCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary")); - - assertThat(result.getCycleCreditsAllocated()).isEqualTo(750); - assertThat(result.getCycleCreditsRemaining()).isEqualTo(750); - } - - @Test - @DisplayName("falls back to 500 when the allocation map has no ROLE_PRO_USER entry") - void fallsBackTo500WhenKeyMissing() { - CreditsProperties custom = new CreditsProperties(); - custom.getCycle().setAllocations(Map.of("ROLE_USER", 50)); - TeamCreditService svc = - new TeamCreditService( - teamCreditRepository, - membershipRepository, - custom, - stripeUsageReportingService, - saasUserExtensionService); - Team team = team(100L); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - when(teamCreditRepository.save(any(TeamCredit.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary")); - - assertThat(result.getCycleCreditsAllocated()).isEqualTo(500); - assertThat(result.getCycleCreditsRemaining()).isEqualTo(500); - } - } - - // ---------------------------------------------------------------------------------------- - // hasCreditsAvailable - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("hasCreditsAvailable") - class HasCreditsAvailable { - - @Test - @DisplayName("true when the team pool has cycle or bought credits left") - void trueWhenCreditsRemain() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(3, 0))); - assertThat(service.hasCreditsAvailable(100L)).isTrue(); - } - - @Test - @DisplayName("true when only bought credits remain") - void trueWhenOnlyBoughtRemain() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 5))); - assertThat(service.hasCreditsAvailable(100L)).isTrue(); - } - - @Test - @DisplayName("false when the pool is empty") - void falseWhenPoolEmpty() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 0))); - assertThat(service.hasCreditsAvailable(100L)).isFalse(); - } - - @Test - @DisplayName("false when no credit row exists for the team") - void falseWhenNoRow() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - assertThat(service.hasCreditsAvailable(100L)).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // consumeCredit - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("consumeCredit") - class ConsumeCredit { - - @Test - @DisplayName("true when the atomic update affected a row") - void trueWhenRowUpdated() { - when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(1); - assertThat(service.consumeCredit(100L, 2)).isTrue(); - verify(teamCreditRepository).consumeCredit(100L, 2); - } - - @Test - @DisplayName("false when the atomic update affected no rows (insufficient / conflict)") - void falseWhenNoRowUpdated() { - when(teamCreditRepository.consumeCredit(100L, 5)).thenReturn(0); - assertThat(service.consumeCredit(100L, 5)).isFalse(); - } - } - - // ---------------------------------------------------------------------------------------- - // getCreditSummaryForUser - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("getCreditSummaryForUser") - class GetCreditSummaryForUser { - - @Test - @DisplayName("empty when the user has no team assigned") - void emptyWhenNoTeam() { - User u = user(7L, "noteam"); - u.setTeam(null); - - Optional result = service.getCreditSummaryForUser(u); - - assertThat(result).isEmpty(); - verifyNoInteractions(teamCreditRepository); - } - - @Test - @DisplayName("delegates to the repository for the user's team id") - void delegatesToRepository() { - User u = user(7L, "withteam"); - u.setTeam(team(200L)); - TeamCredit row = credit(10, 0); - when(teamCreditRepository.findByTeamId(200L)).thenReturn(Optional.of(row)); - - Optional result = service.getCreditSummaryForUser(u); - - assertThat(result).containsSame(row); - verify(teamCreditRepository).findByTeamId(200L); - } - } - - // ---------------------------------------------------------------------------------------- - // getTeamCredits - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("getTeamCredits") - class GetTeamCredits { - - @Test - @DisplayName("passes through the repository optional when present") - void presentPassesThrough() { - TeamCredit row = credit(5, 5); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row)); - assertThat(service.getTeamCredits(100L)).containsSame(row); - } - - @Test - @DisplayName("empty when the repository has no row") - void emptyWhenAbsent() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - assertThat(service.getTeamCredits(100L)).isEmpty(); - } - } - - // ---------------------------------------------------------------------------------------- - // addBoughtCredits - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("addBoughtCredits") - class AddBoughtCredits { - - @Test - @DisplayName("adds to bought and total counters then saves") - void addsAndSaves() { - TeamCredit row = credit(0, 10); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row)); - - service.addBoughtCredits(100L, 25); - - assertThat(row.getBoughtCreditsRemaining()).isEqualTo(35); - assertThat(row.getTotalBoughtCredits()).isEqualTo(35); - verify(teamCreditRepository).save(row); - } - - @Test - @DisplayName("throws IllegalArgumentException when no credit row exists") - void throwsWhenMissing() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.addBoughtCredits(100L, 25)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Team credits not found"); - verify(teamCreditRepository, never()).save(any()); - } - } - - // ---------------------------------------------------------------------------------------- - // resetCycleCredits - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("resetCycleCredits") - class ResetCycleCredits { - - @Test - @DisplayName("overwrites cycle allocation/remaining and reset timestamp then saves") - void resetsAndSaves() { - TeamCredit row = credit(1, 0); - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row)); - LocalDateTime resetTime = LocalDateTime.of(2026, 1, 1, 2, 0); - - service.resetCycleCredits(100L, 600, resetTime); - - assertThat(row.getCycleCreditsAllocated()).isEqualTo(600); - assertThat(row.getCycleCreditsRemaining()).isEqualTo(600); - assertThat(row.getLastCycleResetAt()).isEqualTo(resetTime); - verify(teamCreditRepository).save(row); - } - - @Test - @DisplayName("throws IllegalArgumentException when no credit row exists") - void throwsWhenMissing() { - when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> service.resetCycleCredits(100L, 600, LocalDateTime.now())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Team credits not found"); - verify(teamCreditRepository, never()).save(any()); - } - } - - // ---------------------------------------------------------------------------------------- - // consumeCreditWithWaterfall - // ---------------------------------------------------------------------------------------- - - @Nested - @DisplayName("consumeCreditWithWaterfall") - class ConsumeCreditWithWaterfall { - - @Test - @DisplayName("pool hit returns success(TEAM_CREDITS) and never touches overage billing") - void poolHitShortCircuits() { - when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(1); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("TEAM_CREDITS"); - verifyNoInteractions(membershipRepository); - verifyNoInteractions(saasUserExtensionService); - verifyNoInteractions(stripeUsageReportingService); - } - - @Test - @DisplayName("pool exhausted and no leader returns failure(NO_TEAM_LEADER)") - void poolExhaustedNoLeader() { - when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of()); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3); - - assertThat(result.isSuccess()).isFalse(); - // failure(reason) sets the message to the reason code. - assertThat(result.getMessage()).isEqualTo("NO_TEAM_LEADER"); - verifyNoInteractions(saasUserExtensionService); - verifyNoInteractions(stripeUsageReportingService); - } - - @Test - @DisplayName("leader without metered billing returns the no-overage failure message") - void leaderMeteredBillingDisabled() { - User leader = user(9L, "leader"); - leader.setSupabaseId(UUID.randomUUID()); - when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(false); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).contains("Team credits exhausted"); - verifyNoInteractions(stripeUsageReportingService); - } - - @Test - @DisplayName("leader with metered billing but no Supabase id returns LEADER_NO_SUPABASE_ID") - void leaderMissingSupabaseId() { - User leader = user(9L, "leader"); - leader.setSupabaseId(null); - when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).isEqualTo("LEADER_NO_SUPABASE_ID"); - verifyNoInteractions(stripeUsageReportingService); - } - - @Test - @DisplayName("successful Stripe overage report returns success(TEAM_LEADER_METERED)") - void overageReportedSuccessfully() { - UUID supabaseId = UUID.randomUUID(); - User leader = user(9L, "leader"); - leader.setSupabaseId(supabaseId); - when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - eq(supabaseId.toString()), eq(4), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - supabaseId.toString(), 4, "idem-key")) - .thenReturn(true); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4); - - assertThat(result.isSuccess()).isTrue(); - assertThat(result.getSource()).isEqualTo("TEAM_LEADER_METERED"); - verify(stripeUsageReportingService) - .reportUsageToStripe(supabaseId.toString(), 4, "idem-key"); - } - - @Test - @DisplayName("uses the MDC requestId as the stable operation id for the idempotency key") - void usesMdcRequestIdForIdempotency() { - UUID supabaseId = UUID.randomUUID(); - User leader = user(9L, "leader"); - leader.setSupabaseId(supabaseId); - MDC.put("requestId", "req-123"); - when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - anyString(), anyInt(), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - anyString(), anyInt(), anyString())) - .thenReturn(true); - - service.consumeCreditWithWaterfall(100L, 2); - - verify(stripeUsageReportingService) - .generateIdempotencyKey(supabaseId.toString(), 2, "req-123"); - } - - @Test - @DisplayName("falls back to a random operation id when no requestId is in MDC") - void generatesRandomOperationIdWhenNoMdc() { - UUID supabaseId = UUID.randomUUID(); - User leader = user(9L, "leader"); - leader.setSupabaseId(supabaseId); - // No MDC requestId set -> a random UUID string is generated. - when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - anyString(), anyInt(), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - anyString(), anyInt(), anyString())) - .thenReturn(true); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 2); - - assertThat(result.isSuccess()).isTrue(); - ArgumentCaptor opId = ArgumentCaptor.forClass(String.class); - verify(stripeUsageReportingService) - .generateIdempotencyKey(eq(supabaseId.toString()), eq(2), opId.capture()); - assertThat(opId.getValue()).isNotBlank(); - } - - @Test - @DisplayName("Stripe report returning false yields failure(STRIPE_REPORTING_FAILED)") - void overageReportFailed() { - UUID supabaseId = UUID.randomUUID(); - User leader = user(9L, "leader"); - leader.setSupabaseId(supabaseId); - when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - anyString(), anyInt(), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - anyString(), anyInt(), anyString())) - .thenReturn(false); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).contains("Unable to report usage to Stripe"); - } - - @Test - @DisplayName("exception during reporting is caught and surfaced as STRIPE_REPORTING_ERROR") - void overageReportThrows() { - UUID supabaseId = UUID.randomUUID(); - User leader = user(9L, "leader"); - leader.setSupabaseId(supabaseId); - when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(leader))); - when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - anyString(), anyInt(), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - anyString(), anyInt(), anyString())) - .thenThrow(new RuntimeException("boom")); - - CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4); - - assertThat(result.isSuccess()).isFalse(); - assertThat(result.getMessage()).contains("Error reporting usage: boom"); - } - - @Test - @DisplayName("picks the first leader when several leaders exist on the team") - void picksFirstLeaderOfMany() { - UUID firstSupabaseId = UUID.randomUUID(); - User first = user(1L, "first-leader"); - first.setSupabaseId(firstSupabaseId); - User second = user(2L, "second-leader"); - second.setSupabaseId(UUID.randomUUID()); - when(teamCreditRepository.consumeCredit(100L, 1)).thenReturn(0); - when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER)) - .thenReturn(List.of(leaderMembership(first), leaderMembership(second))); - when(saasUserExtensionService.isMeteredBillingEnabled(first)).thenReturn(true); - when(stripeUsageReportingService.generateIdempotencyKey( - anyString(), anyInt(), anyString())) - .thenReturn("idem-key"); - when(stripeUsageReportingService.reportUsageToStripe( - anyString(), anyInt(), anyString())) - .thenReturn(true); - - service.consumeCreditWithWaterfall(100L, 1); - - // Only the first leader's identity is checked / reported on. - verify(saasUserExtensionService).isMeteredBillingEnabled(first); - verify(saasUserExtensionService, never()).isMeteredBillingEnabled(second); - verify(stripeUsageReportingService) - .generateIdempotencyKey(eq(firstSupabaseId.toString()), eq(1), anyString()); - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java index d85c6be8f..f73bf0dae 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/UserRoleServiceTest.java @@ -1,13 +1,9 @@ package stirling.software.saas.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.HashMap; -import java.util.Map; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -25,20 +21,12 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; /** * Unit tests for {@link UserRoleService}. * - *

    The service is a thin orchestrator: it flips a user's {@link Authority} row, mirrors the role - * into the denormalized {@code roleName} column, and (for the upgrade/downgrade helpers) resets the - * cycle credit allocation via {@link CreditService}. All collaborators are mocked; allocation math - * is pure arithmetic and asserted exactly. - * - *

    Allocation note: {@link CreditsProperties}'s default map already carries ROLE_USER=50 and - * ROLE_PRO_USER=500, so {@code getOrDefault} returns those, NOT the inline 25/100 fallbacks baked - * into the service. The inline fallbacks only surface when the allocations map lacks the key, which - * is exercised separately with an emptied map. + *

    The service is a thin orchestrator: it flips a user's {@link Authority} row and mirrors the + * role into the denormalized {@code roleName} column. All collaborators are mocked. */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -46,33 +34,12 @@ class UserRoleServiceTest { @Mock private UserRepository userRepository; @Mock private AuthorityRepository authorityRepository; - @Mock private CreditService creditService; private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER" private static final String ROLE_PRO_USER = Role.PRO_USER.getRoleId(); // "ROLE_PRO_USER" - /** Build the service with the supplied properties (allocations differ per test). */ - private UserRoleService service(CreditsProperties props) { - return new UserRoleService(userRepository, authorityRepository, creditService, props); - } - - /** Default properties: allocations map carries ROLE_USER=50, ROLE_PRO_USER=500. */ - private static CreditsProperties defaultProps() { - return new CreditsProperties(); - } - - /** Properties whose allocations map is empty, forcing the service's inline 25/100 fallbacks. */ - private static CreditsProperties emptyAllocationsProps() { - CreditsProperties p = new CreditsProperties(); - p.getCycle().setAllocations(new HashMap<>()); - return p; - } - - /** Properties whose allocations map holds the exact values we want to assert against. */ - private static CreditsProperties propsWith(Map allocations) { - CreditsProperties p = new CreditsProperties(); - p.getCycle().setAllocations(new HashMap<>(allocations)); - return p; + private UserRoleService service() { + return new UserRoleService(userRepository, authorityRepository); } private static User user(long id, String username, String currentRole) { @@ -107,7 +74,7 @@ class UserRoleServiceTest { @Test @DisplayName("flips the Authority row, mirrors roleName, and persists both") void flipsAuthorityAndMirrorsRoleName() { - UserRoleService service = service(defaultProps()); + UserRoleService service = service(); User u = user(42L, "alice@example.com", ROLE_USER); Authority auth = authority(ROLE_USER); when(authorityRepository.findByUserId(42L)).thenReturn(auth); @@ -120,15 +87,12 @@ class UserRoleServiceTest { // Denormalized column mirrored on the User entity and saved. assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER); verify(userRepository).save(u); - // No credit reset on the bare changeRole path. - verify(creditService, never()) - .resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt()); } @Test @DisplayName("persists the authority before the user (authority-first ordering)") void persistsAuthorityBeforeUser() { - UserRoleService service = service(defaultProps()); + UserRoleService service = service(); User u = user(42L, "alice@example.com", ROLE_USER); Authority auth = authority(ROLE_USER); when(authorityRepository.findByUserId(42L)).thenReturn(auth); @@ -143,7 +107,7 @@ class UserRoleServiceTest { @Test @DisplayName("looks the authority up by the user's numeric id") void looksUpAuthorityByUserId() { - UserRoleService service = service(defaultProps()); + UserRoleService service = service(); User u = user(99L, "bob@example.com", ROLE_PRO_USER); Authority auth = authority(ROLE_PRO_USER); when(authorityRepository.findByUserId(99L)).thenReturn(auth); @@ -158,7 +122,7 @@ class UserRoleServiceTest { @Test @DisplayName("setting the same role is a harmless no-op rewrite that still persists") void sameRoleStillPersists() { - UserRoleService service = service(defaultProps()); + UserRoleService service = service(); User u = user(42L, "carol@example.com", ROLE_USER); Authority auth = authority(ROLE_USER); when(authorityRepository.findByUserId(42L)).thenReturn(auth); @@ -173,7 +137,7 @@ class UserRoleServiceTest { @Test @DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")") void emptyAuthoritiesSetIsTolerated() { - UserRoleService service = service(defaultProps()); + UserRoleService service = service(); User u = user(42L, "dave@example.com", null); // getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log. Authority auth = authority(null); @@ -191,9 +155,9 @@ class UserRoleServiceTest { class DowngradeToFree { @Test - @DisplayName("sets ROLE_USER and resets credits to the configured FREE allocation (50)") - void setsUserRoleAndResetsCreditsFromConfig() { - UserRoleService service = service(defaultProps()); + @DisplayName("sets ROLE_USER, mirrors roleName, and persists both") + void setsUserRole() { + UserRoleService service = service(); User u = user(42L, "eve@example.com", ROLE_PRO_USER); Authority auth = authority(ROLE_PRO_USER); when(authorityRepository.findByUserId(42L)).thenReturn(auth); @@ -204,49 +168,6 @@ class UserRoleServiceTest { assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER); verify(authorityRepository).save(auth); verify(userRepository).save(u); - // Config map has ROLE_USER=50, so getOrDefault returns 50 (NOT the inline 25 fallback). - verify(creditService).resetCycleAllocationForRoleChange(42L, 50); - } - - @Test - @DisplayName("uses the inline 25 fallback when the allocations map omits ROLE_USER") - void usesInlineFallbackWhenAllocationMissing() { - UserRoleService service = service(emptyAllocationsProps()); - User u = user(42L, "frank@example.com", ROLE_PRO_USER); - Authority auth = authority(ROLE_PRO_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.downgradeToFree(u); - - verify(creditService).resetCycleAllocationForRoleChange(42L, 25); - } - - @Test - @DisplayName("honours an explicit ROLE_USER allocation override") - void honoursExplicitAllocationOverride() { - UserRoleService service = service(propsWith(Map.of(ROLE_USER, 7))); - User u = user(42L, "grace@example.com", ROLE_PRO_USER); - Authority auth = authority(ROLE_PRO_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.downgradeToFree(u); - - verify(creditService).resetCycleAllocationForRoleChange(42L, 7); - } - - @Test - @DisplayName("changes the role before resetting credits (role flip precedes the reset)") - void changesRoleBeforeResettingCredits() { - UserRoleService service = service(defaultProps()); - User u = user(42L, "heidi@example.com", ROLE_PRO_USER); - Authority auth = authority(ROLE_PRO_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.downgradeToFree(u); - - InOrder order = Mockito.inOrder(userRepository, creditService); - order.verify(userRepository).save(u); - order.verify(creditService).resetCycleAllocationForRoleChange(42L, 50); } } @@ -255,9 +176,9 @@ class UserRoleServiceTest { class UpgradeToPro { @Test - @DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)") - void setsProRoleAndResetsCreditsFromConfig() { - UserRoleService service = service(defaultProps()); + @DisplayName("sets ROLE_PRO_USER, mirrors roleName, and persists both") + void setsProRole() { + UserRoleService service = service(); User u = user(42L, "ivan@example.com", ROLE_USER); Authority auth = authority(ROLE_USER); when(authorityRepository.findByUserId(42L)).thenReturn(auth); @@ -268,111 +189,6 @@ class UserRoleServiceTest { assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER); verify(authorityRepository).save(auth); verify(userRepository).save(u); - // Config map has ROLE_PRO_USER=500, so getOrDefault returns 500 (NOT inline 100). - verify(creditService).resetCycleAllocationForRoleChange(42L, 500); - } - - @Test - @DisplayName("uses the inline 100 fallback when the allocations map omits ROLE_PRO_USER") - void usesInlineFallbackWhenAllocationMissing() { - UserRoleService service = service(emptyAllocationsProps()); - User u = user(42L, "judy@example.com", ROLE_USER); - Authority auth = authority(ROLE_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.upgradeToPro(u); - - verify(creditService).resetCycleAllocationForRoleChange(42L, 100); - } - - @Test - @DisplayName("honours an explicit ROLE_PRO_USER allocation override") - void honoursExplicitAllocationOverride() { - UserRoleService service = service(propsWith(Map.of(ROLE_PRO_USER, 9999))); - User u = user(42L, "mallory@example.com", ROLE_USER); - Authority auth = authority(ROLE_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.upgradeToPro(u); - - verify(creditService).resetCycleAllocationForRoleChange(42L, 9999); - } - - @Test - @DisplayName("changes the role before resetting credits (role flip precedes the reset)") - void changesRoleBeforeResettingCredits() { - UserRoleService service = service(defaultProps()); - User u = user(42L, "niaj@example.com", ROLE_USER); - Authority auth = authority(ROLE_USER); - when(authorityRepository.findByUserId(42L)).thenReturn(auth); - - service.upgradeToPro(u); - - InOrder order = Mockito.inOrder(userRepository, creditService); - order.verify(userRepository).save(u); - order.verify(creditService).resetCycleAllocationForRoleChange(42L, 500); - } - } - - @Nested - @DisplayName("getCreditAllocationForRole") - class GetCreditAllocationForRole { - - @Test - @DisplayName("returns the configured allocation when the role is present in the map") - void returnsConfiguredAllocation() { - UserRoleService service = service(defaultProps()); - - // Default map: ROLE_USER=50, ROLE_PRO_USER=500, ROLE_ADMIN=1000. - assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(50); - assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(500); - assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(1000); - } - - @Test - @DisplayName("ROLE_USER falls back to 25 when missing from the allocations map") - void userRoleFallsBackTo25() { - UserRoleService service = service(emptyAllocationsProps()); - - assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(25); - } - - @Test - @DisplayName("any non-ROLE_USER role falls back to 100 when missing from the map") - void nonUserRoleFallsBackTo100() { - UserRoleService service = service(emptyAllocationsProps()); - - assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(100); - assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(100); - assertThat(service.getCreditAllocationForRole("ROLE_UNKNOWN")).isEqualTo(100); - } - - @Test - @DisplayName("null role id is treated as non-ROLE_USER and falls back to 100") - void nullRoleIdFallsBackTo100() { - UserRoleService service = service(emptyAllocationsProps()); - - // getOrDefault(null, ...) misses (no null key); the ternary's equals(null) is false - // -> 100. Must not NPE because Role.USER.getRoleId().equals(roleId) is null-safe. - assertThat(service.getCreditAllocationForRole(null)).isEqualTo(100); - } - - @Test - @DisplayName("a configured value of zero is returned verbatim, not replaced by a fallback") - void zeroAllocationReturnedVerbatim() { - UserRoleService service = service(propsWith(Map.of("ROLE_WEB_ONLY_USER", 0))); - - assertThat(service.getCreditAllocationForRole("ROLE_WEB_ONLY_USER")).isZero(); - } - - @Test - @DisplayName("getCreditAllocationForRole never touches the persistence collaborators") - void doesNotTouchRepositories() { - UserRoleService service = service(defaultProps()); - - service.getCreditAllocationForRole(ROLE_USER); - - Mockito.verifyNoInteractions(userRepository, authorityRepository, creditService); } } } diff --git a/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java b/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java deleted file mode 100644 index aff58fdf0..000000000 --- a/app/saas/src/test/java/stirling/software/saas/util/CreditHeaderUtilsTest.java +++ /dev/null @@ -1,348 +0,0 @@ -package stirling.software.saas.util; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.Authority; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamCredit; -import stirling.software.saas.model.UserCredit; -import stirling.software.saas.service.CreditService; -import stirling.software.saas.service.SaasTeamExtensionService; -import stirling.software.saas.service.TeamCreditService; - -/** - * Unit tests for {@link CreditHeaderUtils#getRemainingCredits(User, CreditService, - * TeamCreditService)}. - * - *

    The method resolves a remaining-credit balance by routing between two pools: - * - *

      - *
    • Team pool - used only when the user is NOT a limited-API user, has a team, and that - * team is not "personal" per {@link SaasTeamExtensionService#isPersonal}. - *
    • Personal pool - otherwise; looked up by Supabase id first, then API key, else -1. - *
    - * - * Any thrown exception is swallowed and yields the sentinel {@code -1}. All collaborators are - * mocked; the assertions are pure arithmetic on the resolved balance. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class CreditHeaderUtilsTest { - - @Mock private SaasTeamExtensionService saasTeamExtensionService; - @Mock private CreditService creditService; - @Mock private TeamCreditService teamCreditService; - - @InjectMocks private CreditHeaderUtils creditHeaderUtils; - - private static final long TEAM_ID = 77L; - private static final UUID SUPABASE_ID = UUID.fromString("00000000-0000-0000-0000-000000000123"); - private static final String API_KEY = "api-key-abcdef"; - - // --- builders ------------------------------------------------------------------------------- - - private static User user() { - User u = new User(); - u.setUsername("tester"); - return u; - } - - private static Team team(Long id) { - Team t = new Team(); - t.setId(id); - t.setName("team-" + id); - return t; - } - - /** Authority ctor self-registers on the user's authority set. */ - private static void grant(User u, String role) { - new Authority(role, u); - } - - private static UserCredit userCredit(int cycle, int bought) { - UserCredit c = new UserCredit(user()); - c.setCycleCreditsRemaining(cycle); - c.setBoughtCreditsRemaining(bought); - return c; - } - - private static TeamCredit teamCredit(int cycle, int bought) { - TeamCredit c = new TeamCredit(team(TEAM_ID)); - c.setCycleCreditsRemaining(cycle); - c.setBoughtCreditsRemaining(bought); - return c; - } - - private int call(User u) { - return creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService); - } - - // --- team pool routing ---------------------------------------------------------------------- - - @Nested - @DisplayName("team pool path (non-limited user on a non-personal team)") - class TeamPool { - - @Test - @DisplayName( - "returns the team's total available credits and never touches personal lookups") - void nonPersonalTeam_usesTeamPool() { - User u = user(); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.getTeamCredits(TEAM_ID)) - .thenReturn(Optional.of(teamCredit(40, 10))); - - assertThat(call(u)).isEqualTo(50); - - verify(teamCreditService).getTeamCredits(TEAM_ID); - verifyNoInteractions(creditService); - } - - @Test - @DisplayName("missing team credit row yields the -1 sentinel") - void nonPersonalTeam_missingRow_returnsMinusOne() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.getTeamCredits(TEAM_ID)).thenReturn(Optional.empty()); - - assertThat(call(u)).isEqualTo(-1); - - // Team path chosen, so the personal supabase lookup must never run. - verifyNoInteractions(creditService); - } - - @Test - @DisplayName("zero team credits returns 0, not the sentinel") - void nonPersonalTeam_zeroBalance_returnsZero() { - User u = user(); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.getTeamCredits(TEAM_ID)) - .thenReturn(Optional.of(teamCredit(0, 0))); - - assertThat(call(u)).isZero(); - } - } - - // --- personal pool routing ------------------------------------------------------------------ - - @Nested - @DisplayName("personal pool path") - class PersonalPool { - - @Test - @DisplayName("personal team falls through to the user's individual credits") - void personalTeam_usesPersonalCredits() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true); - when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString())) - .thenReturn(Optional.of(userCredit(15, 5))); - - assertThat(call(u)).isEqualTo(20); - - // Personal path chosen -> team pool untouched. - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName( - "no team at all uses personal credits and never consults the extension service") - void noTeam_usesPersonalCredits() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString())) - .thenReturn(Optional.of(userCredit(7, 0))); - - assertThat(call(u)).isEqualTo(7); - - verifyNoInteractions(saasTeamExtensionService); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("supabase id is preferred over api key when both are present") - void supabaseIdPreferredOverApiKey() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - u.setApiKey(API_KEY); - when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString())) - .thenReturn(Optional.of(userCredit(3, 4))); - - assertThat(call(u)).isEqualTo(7); - - verify(creditService).getUserCreditsBySupabaseId(SUPABASE_ID.toString()); - verify(creditService, never()).getUserCreditsByApiKey(anyString()); - } - - @Test - @DisplayName("falls back to api key lookup when supabase id is absent") - void apiKeyFallback_whenNoSupabaseId() { - User u = user(); - u.setApiKey(API_KEY); - when(creditService.getUserCreditsByApiKey(API_KEY)) - .thenReturn(Optional.of(userCredit(9, 1))); - - assertThat(call(u)).isEqualTo(10); - - verify(creditService).getUserCreditsByApiKey(API_KEY); - verify(creditService, never()).getUserCreditsBySupabaseId(anyString()); - } - - @Test - @DisplayName("supabase lookup returning empty yields -1") - void supabaseLookupEmpty_returnsMinusOne() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString())) - .thenReturn(Optional.empty()); - - assertThat(call(u)).isEqualTo(-1); - } - - @Test - @DisplayName("api key lookup returning empty yields -1") - void apiKeyLookupEmpty_returnsMinusOne() { - User u = user(); - u.setApiKey(API_KEY); - when(creditService.getUserCreditsByApiKey(API_KEY)).thenReturn(Optional.empty()); - - assertThat(call(u)).isEqualTo(-1); - } - - @Test - @DisplayName("neither supabase id nor api key present yields -1 with no lookups") - void noIdentifiers_returnsMinusOne() { - User u = user(); // no supabaseId, no apiKey, no team - - assertThat(call(u)).isEqualTo(-1); - - verifyNoInteractions(creditService); - verifyNoInteractions(teamCreditService); - } - } - - // --- limited-api user override -------------------------------------------------------------- - - @Nested - @DisplayName("limited-api users always read personal credits") - class LimitedApiOverride { - - @Test - @DisplayName("ROLE_LIMITED_API_USER on a non-personal team still reads personal credits") - void limitedApiUser_skipsTeamPool() { - User u = user(); - u.setApiKey(API_KEY); - u.setTeam(team(TEAM_ID)); - grant(u, "ROLE_LIMITED_API_USER"); - when(creditService.getUserCreditsByApiKey(API_KEY)) - .thenReturn(Optional.of(userCredit(2, 0))); - - assertThat(call(u)).isEqualTo(2); - - // The team branch is gated out, so neither the extension nor team-credit services run. - verifyNoInteractions(saasTeamExtensionService); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName("ROLE_EXTRA_LIMITED_API_USER also forces the personal pool") - void extraLimitedApiUser_skipsTeamPool() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - u.setTeam(team(TEAM_ID)); - grant(u, "ROLE_EXTRA_LIMITED_API_USER"); - when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString())) - .thenReturn(Optional.of(userCredit(6, 0))); - - assertThat(call(u)).isEqualTo(6); - - verifyNoInteractions(saasTeamExtensionService); - verify(teamCreditService, never()).getTeamCredits(any()); - } - - @Test - @DisplayName( - "a non-limited role does not divert a non-personal team member off the team pool") - void unrelatedRole_keepsTeamPool() { - User u = user(); - u.setTeam(team(TEAM_ID)); - grant(u, "ROLE_USER"); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.getTeamCredits(TEAM_ID)) - .thenReturn(Optional.of(teamCredit(11, 0))); - - assertThat(call(u)).isEqualTo(11); - - verify(teamCreditService).getTeamCredits(TEAM_ID); - verifyNoInteractions(creditService); - } - } - - // --- error swallowing ----------------------------------------------------------------------- - - @Nested - @DisplayName("exceptions are swallowed and produce the -1 sentinel") - class ErrorHandling { - - @Test - @DisplayName("team credit service throwing is caught and returns -1") - void teamServiceThrows_returnsMinusOne() { - User u = user(); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false); - when(teamCreditService.getTeamCredits(TEAM_ID)) - .thenThrow(new RuntimeException("db down")); - - assertThat(call(u)).isEqualTo(-1); - } - - @Test - @DisplayName("credit service throwing on personal lookup is caught and returns -1") - void creditServiceThrows_returnsMinusOne() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - when(creditService.getUserCreditsBySupabaseId(anyString())) - .thenThrow(new RuntimeException("lookup boom")); - - assertThat(call(u)).isEqualTo(-1); - } - - @Test - @DisplayName( - "extension service throwing during personal-team check is caught and returns -1") - void extensionServiceThrows_returnsMinusOne() { - User u = user(); - u.setSupabaseId(SUPABASE_ID); - u.setTeam(team(TEAM_ID)); - when(saasTeamExtensionService.isPersonal(u.getTeam())) - .thenThrow(new RuntimeException("extension boom")); - - assertThat(call(u)).isEqualTo(-1); - } - } -} diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 0e9b5957b..df0d9fbc7 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen" title = "Choose colour" [common] -available = "available" back = "Back" cancel = "Cancel" close = "Close" @@ -2774,7 +2773,6 @@ previous = "Previous" refresh = "Refresh" retry = "Retry" save = "Save" -used = "used" [compare] clearSelected = "Clear selected" @@ -3020,9 +3018,7 @@ title = "Upgrade Guest Account" upgradeButton = "Upgrade Account" [config.apiKeys] -chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}" copyKeyAriaLabel = "Copy API key" -creditsRemaining = "Credits Remaining" description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one." docsDescription = "Learn more about integrating with Stirling PDF:" docsLink = "API Documentation" @@ -3030,14 +3026,9 @@ docsTitle = "API Documentation" generateError = "We couldn't generate your API key." goToAccount = "Go to Account" guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications." -includedCredits = "Included credits" intro = "Use your API key to programmatically access Stirling PDF's processing capabilities." label = "API Key" -lastApiUse = "Last API Use" -nextReset = "Next Reset" -overlayMessage = "Generate a key to see credits and available credits" publicKeyAriaLabel = "Public API key" -purchasedCredits = "Purchased credits" refreshAriaLabel = "Refresh API key" schemaLink = "API Schema Reference" usage = "Include this key in the X-API-KEY header with all API requests." diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 01a9eceac..bd279596b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -2730,7 +2730,6 @@ tooltip = "Pick color from screen" title = "Choose color" [common] -available = "available" back = "Back" cancel = "Cancel" close = "Close" @@ -2748,7 +2747,6 @@ previous = "Previous" refresh = "Refresh" retry = "Retry" save = "Save" -used = "used" [compare] clearSelected = "Clear selected" @@ -2994,9 +2992,7 @@ title = "Upgrade Guest Account" upgradeButton = "Upgrade Account" [config.apiKeys] -chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}" copyKeyAriaLabel = "Copy API key" -creditsRemaining = "Credits Remaining" description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one." docsDescription = "Learn more about integrating with Stirling PDF:" docsLink = "API Documentation" @@ -3004,14 +3000,9 @@ docsTitle = "API Documentation" generateError = "We couldn't generate your API key." goToAccount = "Go to Account" guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications." -includedCredits = "Included credits" intro = "Use your API key to programmatically access Stirling PDF's processing capabilities." label = "API Key" -lastApiUse = "Last API Use" -nextReset = "Next Reset" -overlayMessage = "Generate a key to see credits and available credits" publicKeyAriaLabel = "Public API key" -purchasedCredits = "Purchased credits" refreshAriaLabel = "Refresh API key" schemaLink = "API Schema Reference" usage = "Include this key in the X-API-KEY header with all API requests." diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 04dea19c7..087fa99b6 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -14,12 +14,6 @@ import type { User as SupabaseUser, AuthError, } from "@supabase/supabase-js"; -import { - CreditSummary, - SubscriptionInfo, - CreditCheckResult, -} from "@app/types/credits"; -import { setGlobalCreditUpdateCallback } from "@app/services/apiClient"; import { synchronizeUserUpgrade } from "@app/services/userService"; import { syncOAuthAvatar, @@ -70,17 +64,11 @@ interface AuthContextType { isAnonymous: boolean; loading: boolean; error: AuthError | null; - creditBalance: number | null; - subscription: SubscriptionInfo | null; - creditSummary: CreditSummary | null; isPro: boolean | null; profilePictureUrl: string | null; profilePictureMetadata: ProfilePictureMetadata | null; signOut: () => Promise; refreshSession: () => Promise; - hasSufficientCredits: (requiredCredits: number) => CreditCheckResult; - updateCredits: (newBalance: number) => void; - refreshCredits: () => Promise; refreshProStatus: () => Promise; refreshProfilePicture: () => Promise; refreshProfilePictureMetadata: () => Promise; @@ -93,21 +81,11 @@ const AuthContext = createContext({ isAnonymous: false, loading: true, error: null, - creditBalance: null, - subscription: null, - creditSummary: null, isPro: null, profilePictureUrl: null, profilePictureMetadata: null, signOut: async () => {}, refreshSession: async () => {}, - hasSufficientCredits: () => ({ - hasSufficientCredits: false, - currentBalance: 0, - requiredCredits: 0, - }), - updateCredits: () => {}, - refreshCredits: async () => {}, refreshProStatus: async () => {}, refreshProfilePicture: async () => {}, refreshProfilePictureMetadata: async () => {}, @@ -117,13 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [creditBalance, setCreditBalance] = useState(null); - const [subscription, setSubscription] = useState( - null, - ); - const [creditSummary, setCreditSummary] = useState( - null, - ); const [isPro, setIsPro] = useState(null); const [profilePictureUrl, setProfilePictureUrl] = useState( null, @@ -131,19 +102,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [profilePictureMetadata, setProfilePictureMetadata] = useState(null); - // Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via - // useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing - // consumers of useAuth() that still destructure creditBalance / refreshCredits - // compile cleanly; values just stay null forever and refreshCredits is a noop. - // _ underscore on the param keeps the public signature stable for callers. - const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => { - /* legacy credit fetch removed — see comment above */ - }, []); - - const refreshCredits = useCallback(async () => { - /* legacy credit refresh removed — useWallet() replaces this */ - }, []); - const fetchProStatus = useCallback( async (sessionToUse?: Session | null) => { const currentSession = sessionToUse ?? session; @@ -289,46 +247,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchProfilePictureMetadata(); }, [fetchProfilePictureMetadata]); - const updateCredits = useCallback( - (newBalance: number) => { - console.debug("[Auth Debug] Updating credit balance:", { - from: creditBalance, - to: newBalance, - }); - setCreditBalance(newBalance); - // Also update the creditSummary if it exists - if (creditSummary) { - const updatedSummary: CreditSummary = { - ...creditSummary, - creditsRemaining: newBalance, - currentCredits: newBalance, - }; - setCreditSummary(updatedSummary); - } - }, - [creditSummary], - ); - - const hasSufficientCredits = useCallback( - (requiredCredits: number): CreditCheckResult => { - const currentBalance = creditBalance ?? 0; - const hasSufficient = currentBalance >= requiredCredits; - console.debug("[Auth Debug] Credit check:", { - requiredCredits, - currentBalance, - hasSufficient, - }); - - return { - hasSufficientCredits: hasSufficient, - currentBalance, - requiredCredits, - shortfall: hasSufficient ? undefined : requiredCredits - currentBalance, - }; - }, - [creditBalance], - ); - const refreshSession = async () => { try { setLoading(true); @@ -372,11 +290,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - // Set up global credit update callback - useEffect(() => { - setGlobalCreditUpdateCallback(updateCredits); - }, [updateCredits]); - useEffect(() => { let mounted = true; @@ -399,7 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setSession(data.session); - // Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response + // Fetch pro status, profile picture metadata, and profile picture using the session from the response if (data.session?.user) { // Sync OAuth avatar in background; fetch the picture once the // sync settles instead of guessing with a fixed delay. @@ -413,7 +326,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) .then(() => fetchProfilePicture(data.session)); - await fetchCredits(data.session); await fetchProStatus(data.session); await fetchProfilePictureMetadata(data.session); } @@ -458,10 +370,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Additional handling for specific events if (event === "SIGNED_OUT") { console.debug("[Auth Debug] User signed out, clearing session"); - // Clear credit data, pro status, profile picture, and metadata on sign out - setCreditBalance(null); - setCreditSummary(null); - setSubscription(null); + // Clear pro status, profile picture, and metadata on sign out setIsPro(null); setProfilePictureUrl(null); setProfilePictureMetadata(null); @@ -490,7 +399,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Fetch user data in parallel Promise.all([ - fetchCredits(newSession), fetchProStatus(newSession), fetchProfilePictureMetadata(newSession), ]).then(() => { @@ -506,10 +414,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } else if (event === "TOKEN_REFRESHED") { console.debug("[Auth Debug] Token refreshed"); - // Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh + // Optionally refresh pro status, profile picture metadata, and profile picture on token refresh if (newSession?.user) { Promise.all([ - fetchCredits(newSession), fetchProStatus(newSession), fetchProfilePictureMetadata(newSession), fetchProfilePicture(newSession), @@ -547,10 +454,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] User upgrade synchronized successfully", ); - // Refresh credits, pro status, profile picture metadata, and profile picture after upgrade + // Refresh pro status, profile picture metadata, and profile picture after upgrade if (newSession?.user) { return Promise.all([ - fetchCredits(newSession), fetchProStatus(newSession), fetchProfilePictureMetadata(newSession), fetchProfilePicture(newSession), @@ -589,17 +495,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAnonymous: Boolean(user?.is_anonymous), loading, error, - creditBalance, - subscription, - creditSummary, isPro, profilePictureUrl, profilePictureMetadata, signOut, refreshSession, - hasSufficientCredits, - updateCredits, - refreshCredits, refreshProStatus, refreshProfilePicture, refreshProfilePictureMetadata, diff --git a/frontend/editor/src/saas/auth/teamSession.ts b/frontend/editor/src/saas/auth/teamSession.ts index 16a71069f..c31acc04b 100644 --- a/frontend/editor/src/saas/auth/teamSession.ts +++ b/frontend/editor/src/saas/auth/teamSession.ts @@ -17,19 +17,18 @@ import type { TeamAuth } from "@cloud/auth/teamSession"; export type { TeamAuth } from "@cloud/auth/teamSession"; export function useTeamAuth(): TeamAuth { - const { user, refreshCredits, refreshSession } = useAuth(); + const { user, refreshSession } = useAuth(); // Teams require a signed-in, non-anonymous user — anonymous (guest) sessions // never load or manage teams. const canUseTeams = !!user && !isUserAnonymous(user); // After a membership change the user's billing tier may have changed, so - // refresh credits + the Supabase session (mirrors the pre-move accept/leave - // flow in saas SaaSTeamContext). + // refresh the Supabase session (mirrors the pre-move accept/leave flow in + // saas SaaSTeamContext). const refreshAfterMembershipChange = useCallback(async () => { - await refreshCredits(); await refreshSession(); - }, [refreshCredits, refreshSession]); + }, [refreshSession]); return { canUseTeams, refreshAfterMembershipChange }; } diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index c226cab70..0fa14721d 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -24,7 +24,7 @@ interface AppConfigModalProps { const AppConfigModal: React.FC = ({ opened, onClose }) => { const isMobile = useMediaQuery("(max-width: 1024px)"); - const { signOut, user, creditBalance, refreshCredits } = useAuth(); + const { signOut, user } = useAuth(); const { t } = useTranslation(); const [confirmOpen, setConfirmOpen] = useState(false); const [active, setActive] = useState("overview"); @@ -95,35 +95,6 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { window.removeEventListener("appConfig:overlay", handler as EventListener); }, []); - // When the modal opens to Plan, proactively refresh credits and log values - useEffect(() => { - if (!opened) return; - if (active !== "plan") return; - console.log( - "[AppConfigModal] Opening Plan section. Current creditBalance:", - creditBalance, - ); - (async () => { - try { - await refreshCredits(); - } catch (e) { - console.warn( - "[AppConfigModal] Failed to refresh credits on Plan open:", - e, - ); - } - })(); - }, [opened, active]); - - useEffect(() => { - if (!opened) return; - if (active !== "plan") return; - console.log( - "[AppConfigModal] Credit balance updated while viewing Plan:", - creditBalance, - ); - }, [opened, active, creditBalance]); - const colors = useMemo( () => ({ navBg: "var(--modal-nav-bg)", diff --git a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx index 0caf6d9bd..bee502e70 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx @@ -1,9 +1,7 @@ import React, { useState } from "react"; import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core"; -import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection"; import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection"; import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal"; -import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits"; import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey"; import SkeletonLoader from "@app/components/shared/SkeletonLoader"; import { useTranslation } from "react-i18next"; @@ -17,7 +15,6 @@ export default function ApiKeys() { const { user } = useAuth(); const isAnonymous = Boolean(user && isUserAnonymous(user)); - const { data: credits, isLoading: creditsLoading } = useCredits(); const { apiKey, isLoading: apiKeyLoading, @@ -25,7 +22,6 @@ export default function ApiKeys() { isRefreshing, error: apiKeyError, refetch, - hasAttempted, } = useApiKey(); const copy = async (text: string, tag: string) => { @@ -53,22 +49,8 @@ export default function ApiKeys() { ); }; - const showUsage = Boolean(credits); - return ( - {showUsage && ( - - )} - {!isAnonymous && apiKeyError && ( {t( diff --git a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/UsageSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/UsageSection.tsx deleted file mode 100644 index f7f4e5468..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/UsageSection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from "react"; -import { Paper, Stack, Group, Text, Divider } from "@mantine/core"; -import StackedBarChart from "@app/components/shared/charts/StackedBarChart"; -import { FractionData } from "@app/types/charts"; -import { ApiCredits as ApiUsage } from "@app/types/credits"; -import SkeletonLoader from "@app/components/shared/SkeletonLoader"; -import { formatUTC } from "@app/components/shared/utils/date"; -import { useTranslation } from "react-i18next"; - -// Using shared ApiCredits type as ApiUsage - -interface UsageSectionProps { - apiUsage: ApiUsage; - obscured?: boolean; - overlayMessage?: string; - loading?: boolean; -} - -export default function UsageSection({ - apiUsage, - obscured, - overlayMessage, - loading, -}: UsageSectionProps) { - const { t } = useTranslation(); - const weeklyUsed = - apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining; - const boughtUsed = - apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining; - - // Totals for overall usage visualization - const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0); - - const formatDate = (iso: string, withTime: boolean) => - formatUTC(iso, withTime); - - // Prepare data for the stacked bar chart - const fractions: FractionData[] = [ - { - name: t("config.apiKeys.includedCredits", "Included credits"), - numerator: Math.max(0, weeklyUsed), - denominator: Math.max(0, apiUsage.weeklyCreditsAllocated), - numeratorLabel: t("common.used", "used"), - denominatorLabel: t("common.available", "available"), - color: "var(--usage-weekly-active)", - }, - { - name: t("config.apiKeys.purchasedCredits", "Purchased credits"), - numerator: Math.max(0, boughtUsed), - denominator: Math.max(0, apiUsage.totalBoughtCredits), - numeratorLabel: t("common.used", "used"), - denominatorLabel: t("common.available", "available"), - color: "var(--usage-bought-active)", - }, - ]; - - return ( -
    - - - - - {t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "} - {loading ? ( - - ) : ( - totalRemaining - )} - - - - - - - - - - - {t("config.apiKeys.nextReset", "Next Reset")}:{" "} - {loading ? ( - - ) : ( - formatDate(apiUsage.weeklyResetDate, false) - )} - - - {t("config.apiKeys.lastApiUse", "Last API Use")}:{" "} - {loading ? ( - - ) : ( - formatDate(apiUsage.lastApiUsage, true) - )} - - - - - - - {obscured && ( -
    - - {overlayMessage || - t( - "config.apiKeys.overlayMessage", - "Generate a key to see credits and available credits", - )} - -
    - )} -
    - ); -} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts deleted file mode 100644 index 5f0f0a321..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useAuth } from "@app/auth/UseSession"; -import { ApiCredits } from "@app/types/credits"; -import { isUserAnonymous } from "@app/auth/supabase"; - -export function useCredits() { - const { session, loading, user } = useAuth(); - const isAnonymous = Boolean(user && isUserAnonymous(user)); - // Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only - // hasAttempted toggles, so the rest have no setters. - const [data] = useState(null); - const [isLoading] = useState(false); - const [error] = useState(null); - const [hasAttempted, setHasAttempted] = useState(false); - - // Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the - // wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The - // hook surface is preserved for the ApiKeys page consumer (it destructures `data`, - // `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null — - // the consumer's usage widget will render its "no data" state. - const fetchCredits = useCallback(async () => { - setHasAttempted(true); - }, []); - - useEffect(() => { - if (!loading && session && !hasAttempted && !isAnonymous) { - setHasAttempted(true); - } - }, [loading, session, hasAttempted, isAnonymous]); - - return { - data, - isLoading, - error, - refetch: fetchCredits, - hasAttempted, - } as const; -} - -export default useCredits; diff --git a/frontend/editor/src/saas/hooks/useCredits.ts b/frontend/editor/src/saas/hooks/useCredits.ts deleted file mode 100644 index 2af071850..000000000 --- a/frontend/editor/src/saas/hooks/useCredits.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useAuth } from "@app/auth/UseSession"; - -/** - * Hook for credit management and checking in tools. - * Provides easy access to credit balance, subscription info, and validation functions. - */ -export const useCredits = () => { - const { - creditBalance, - subscription, - creditSummary, - isPro, - hasSufficientCredits, - updateCredits, - refreshCredits, - } = useAuth(); - - /** - * Get user-friendly credit status message - */ - const getCreditStatusMessage = (): string => { - if (creditBalance === 0) { - return "No credits remaining"; - } - if (creditBalance === null) { - return "Credits loading..."; - } - return `${creditBalance} credits available`; - }; - - return { - // State - creditBalance, - subscription, - creditSummary, - isPro, - - // Actions - refreshCredits, - updateCredits, - - // Utilities - getCreditStatusMessage, - hasSufficientCredits, - }; -}; diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 89bf6357d..aeb6bb527 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -1,24 +1,12 @@ import axios from "axios"; import { supabase } from "@app/auth/supabase"; import { handleHttpError } from "@app/services/httpErrorHandler"; -import { alert } from "@app/components/toast"; -import { openPlanSettings } from "@app/utils/appSettings"; import { classifyPaygError, handlePaygError, } from "@app/services/paygErrorInterceptor"; import { withBasePath } from "@app/constants/app"; -// Global credit update callback - will be set by the AuthProvider -let globalCreditUpdateCallback: ((credits: number) => void) | null = null; - -// Function to set the global credit update callback -export const setGlobalCreditUpdateCallback = ( - callback: (credits: number) => void, -) => { - globalCreditUpdateCallback = callback; -}; - // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { try { @@ -46,20 +34,6 @@ const apiClient = axios.create({ responseType: "json", }); -const LOW_CREDIT_THRESHOLD = 10; -function notifyLowCredits(credits: number) { - const title = "Credit balance low"; - const body = `You have ${credits} credits remaining.`; - alert({ - alertType: "warning", - title, - body, - buttonText: "Top up", - buttonCallback: () => openPlanSettings(), - isPersistentPopup: true, - location: "bottom-right", - }); -} // Request interceptor to add JWT token to all requests apiClient.interceptors.request.use( async (config) => { @@ -136,34 +110,9 @@ function refreshSessionOnce(): ReturnType { return inFlightRefresh; } -// Response interceptor for handling token refresh and credit updates +// Response interceptor for handling token refresh apiClient.interceptors.response.use( - (response) => { - // Check for X-Credits-Remaining header and update credits automatically - const creditsRemaining = response.headers["x-credits-remaining"]; - if (creditsRemaining && globalCreditUpdateCallback) { - const credits = parseInt(creditsRemaining, 10); - if (!isNaN(credits) && credits >= 0) { - console.debug( - "[API Client] Updating credits from response header:", - credits, - "for URL:", - response.config?.url, - ); - globalCreditUpdateCallback(credits); - // Show low-credit toast with top-up button when below threshold - if (credits < LOW_CREDIT_THRESHOLD) { - notifyLowCredits(credits); - } - } else { - console.warn( - "[API Client] Invalid credits value in response header:", - creditsRemaining, - ); - } - } - return response; - }, + (response) => response, async (error) => { const originalRequest = error.config || {}; const status = error.response?.status; diff --git a/frontend/editor/src/saas/types/credits.ts b/frontend/editor/src/saas/types/credits.ts deleted file mode 100644 index f3bc8732a..000000000 --- a/frontend/editor/src/saas/types/credits.ts +++ /dev/null @@ -1,35 +0,0 @@ -export interface ApiCredits { - weeklyCreditsRemaining: number; - weeklyCreditsAllocated: number; - boughtCreditsRemaining: number; - totalBoughtCredits: number; - totalAvailableCredits: number; - weeklyResetDate: string; - lastApiUsage: string; -} - -export interface CreditSummary { - currentCredits: number; - maxCredits: number; - creditsUsed: number; - creditsRemaining: number; - resetDate: string; // ISO date string - weeklyAllowance: number; -} - -export interface SubscriptionInfo { - id?: string; - status: "active" | "inactive" | "cancelled" | "expired"; - tier: "free" | "basic" | "premium" | "enterprise"; - startDate?: string; // ISO date string - endDate?: string; // ISO date string - creditsPerWeek?: number; - maxCredits?: number; -} - -export interface CreditCheckResult { - hasSufficientCredits: boolean; - currentBalance: number; - requiredCredits: number; - shortfall?: number; -}