diff --git a/.github/workflows/docker-compose-tests-saas.yml b/.github/workflows/docker-compose-tests-saas.yml new file mode 100644 index 000000000..c4e2a0834 --- /dev/null +++ b/.github/workflows/docker-compose-tests-saas.yml @@ -0,0 +1,136 @@ +name: Docker Compose Cucumber tests (saas / PAYG) + +# Self-contained CI job for the PAYG shadow-mode cucumber scenarios. +# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR +# that doesn't touch the saas flavour. +# +# Companion to `docker-compose-tests.yml` (which runs against the +# proprietary-flavour stack and skips features/payg via behave.ini's +# exclude_re). Kept as a separate workflow so the saas matrix can fail and +# succeed independently without touching the main cucumber harness. + +on: + pull_request: + paths: + - "app/saas/**" + - "testing/cucumber/features/payg/**" + - "testing/cucumber/features/steps/payg_step_definitions.py" + - "testing/cucumber/requirements.txt" + - "testing/compose/docker-compose-saas.yml" + - "testing/compose/payg/**" + - "testing/test-payg.sh" + - ".github/workflows/docker-compose-tests-saas.yml" + push: + branches: [main] + paths: + - "app/saas/**" + - "testing/cucumber/features/payg/**" + - "testing/cucumber/features/steps/payg_step_definitions.py" + - "testing/cucumber/requirements.txt" + - "testing/compose/docker-compose-saas.yml" + - "testing/compose/payg/**" + - "testing/test-payg.sh" + - ".github/workflows/docker-compose-tests-saas.yml" + +permissions: + contents: read + +jobs: + pick: + uses: ./.github/workflows/_runner-pick.yml + + docker-compose-tests-saas: + needs: pick + runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + permissions: + actions: write + contents: read + checks: write + env: + DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Cache Gradle dependency artifacts + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches/modules-2/files-2.1 + ~/.gradle/caches/modules-2/metadata-2.* + key: gradle-deps-saas-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + gradle-version: 9.3.1 + cache-disabled: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Expose GitHub runtime for Buildx cache + uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + + # No "Install Docker Compose" step: Ubuntu runners ship with `docker compose` + # v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout + # (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary + # isn't needed. Avoids a `curl | sudo install` without checksum verification + # (Aikido flagged this when copy-pasted from docker-compose-tests.yml). + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: ./testing/cucumber/requirements.txt + + - name: Pip requirements + run: | + pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt + + - name: Run PAYG Cucumber Tests + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + run: | + chmod +x ./testing/test-payg.sh + ./testing/test-payg.sh + + - name: Dump saas container logs on failure + if: failure() + run: | + docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true + docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true + + - name: Upload PAYG Cucumber Report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: payg-cucumber-report + path: testing/cucumber/report-payg.html + retention-days: 7 + if-no-files-found: warn + + - name: PAYG Cucumber Test Report + if: always() + uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + with: + name: PAYG Cucumber Tests + path: testing/cucumber/junit-payg/*.xml + reporter: java-junit + fail-on-error: false diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index eb6d8d7be..0759174e7 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -188,30 +188,10 @@ jobs: name: backend-log-live-${{ github.run_id }} path: .test-state/playwright/backend.log retention-days: 7 - - name: List Playwright output locations (debug) - if: always() - run: | - echo "::group::Playwright output dirs" - # Playwright anchors its default outputDir + HTML report to the - # nearest package.json, which is frontend/ (frontend/editor has - # none), so artifacts land under frontend/, not frontend/editor/. - ls -la frontend/playwright-report 2>/dev/null \ - || echo "no playwright-report at frontend/" - ls -la frontend/test-results 2>/dev/null \ - || echo "no test-results at frontend/" - find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true - echo "::endgroup::" - - name: Upload Playwright report + traces + - name: Upload Playwright report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-live-${{ github.run_id }} - # test-results/ holds the per-test trace.zip (with browser console - # logs) + screenshots/video; playwright-report/ is the HTML report. - # Both live under frontend/ (Playwright anchors them to the nearest - # package.json, which is frontend/; frontend/editor has none). - path: | - frontend/playwright-report/ - frontend/test-results/ + path: frontend/editor/playwright-report/ retention-days: 7 - if-no-files-found: warn diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 3bf650446..2a3adffeb 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -18,15 +18,6 @@ version: '3' tasks: dev: desc: "Start backend dev server" - cmds: - - task: dev:proprietary - vars: - PORT: '{{.PORT}}' - AIENGINE_URL: '{{.AIENGINE_URL}}' - AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' - - dev:proprietary: - desc: "Start backend dev server in proprietary mode" ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' @@ -59,14 +50,9 @@ tasks: PORT: '{{.PORT | default "8080"}}' # Override to "" to run the pure `saas` profile against your own SAAS_DB_*. PROFILES: '{{.PROFILES | default "dev"}}' - AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' - AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' env: SERVER_PORT: '{{.PORT}}' STIRLING_FLAVOR: saas - AIENGINE_URL: '{{.AIENGINE_URL}}' - AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}' - AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' cmds: - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" platforms: [windows] diff --git a/.taskfiles/docker.yml b/.taskfiles/docker.yml index cba35493f..abe885a13 100644 --- a/.taskfiles/docker.yml +++ b/.taskfiles/docker.yml @@ -20,11 +20,6 @@ tasks: cmds: - docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite . - build:backend: - desc: "Build backend-only Docker image (no embedded frontend)" - cmds: - - docker build -t stirling-pdf-backend -f docker/backend/Dockerfile . - build:frontend: desc: "Build frontend-only Docker image" cmds: diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index cfe079024..1834495b1 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -1,5 +1,12 @@ version: '3' +vars: + # Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_* + # vars (Task merges included-file vars into the global scope). + # Paths are relative to the engine/ include dir. + ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh" + ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1" + tasks: install: desc: "Install engine dependencies" @@ -29,11 +36,14 @@ tasks: ignore_error: true dir: src vars: - PORT: '{{.PORT | default "5001"}}' + # When PORT is provided (e.g. from dev:all), use it directly. + # When running standalone, probe for a free port starting at 5001. + PORT: + sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}' env: PYTHONUNBUFFERED: "1" cmds: - - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}" + - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} dev: desc: "Start engine dev server with hot reload" @@ -41,7 +51,10 @@ tasks: ignore_error: true dir: src vars: - PORT: '{{.PORT | default "5001"}}' + # When PORT is provided (e.g. from dev:all), use it directly. + # When running standalone, probe for a free port starting at 5001. + PORT: + sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}' env: PYTHONUNBUFFERED: "1" cmds: diff --git a/Taskfile.yml b/Taskfile.yml index 8315829ba..dc4d5130b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -60,20 +60,24 @@ tasks: dev:saas: desc: "Start SaaS backend + frontend concurrently on free ports" - cmds: - - task: dev:_all - vars: { FRONTEND: saas, BACKEND: saas } + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev:saas + vars: + PORT: '{{.BACKEND_PORT}}' + - task: frontend:dev:saas + vars: + PORT: '{{.FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" dev:all: desc: "Start backend + frontend + engine concurrently on free ports" - cmds: - - task: dev:_all - - dev:_all: - internal: true vars: - FRONTEND: '{{.FRONTEND | default "proprietary"}}' - BACKEND: '{{.BACKEND | default "proprietary"}}' PORTS: sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}' BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' @@ -83,11 +87,11 @@ tasks: - task: engine:dev vars: PORT: '{{.ENGINE_PORT}}' - - task: 'backend:dev:{{.BACKEND}}' + - task: backend:dev vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' - - task: 'frontend:dev:{{.FRONTEND}}' + - task: frontend:dev vars: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index b955a2fe3..067be54eb 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -367,15 +367,6 @@ public class ApplicationProperties { */ private String resourceId = ""; - /** - * Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}. - * Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint - * resource-specific audiences - e.g. Supabase's OAuth server always issues {@code - * aud=authenticated} - so operators list the audience their IdP actually emits here - * (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated). - */ - private List acceptedAudiences = new ArrayList<>(); - /** * JWT claim whose value is matched against a provisioned Stirling username. Defaults to * {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP @@ -1005,10 +996,6 @@ public class ApplicationProperties { @Data public static class Signing { private boolean enabled = false; - - // Signing user-picker scope: 'org' (default) = whole instance, anything else = - // caller's team only (fail-closed). The saas profile pins 'team'. - private String userListScope = "org"; } } diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 8df2d5e41..37c110c27 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -50,16 +50,6 @@ public class InternalApiClient { "^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$" + "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$"); - /** - * Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies - * the call as {@code BillingCategory.AUTOMATION}. By construction every {@link - * InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow, - * policy runner) running a child tool inside a parent automation flow — see the saas {@code - * PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any - * per-tool {@code @RequiresFeature} annotation. - */ - public static final String AUTOMATION_HEADER = "X-Stirling-Automation"; - private final ServletContext servletContext; private final UserServiceInterface userService; private final TempFileManager tempFileManager; @@ -106,11 +96,6 @@ public class InternalApiClient { if (apiKey != null && !apiKey.isEmpty()) { headers.add("X-API-KEY", apiKey); } - // Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which - // tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR - // step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because - // every caller of this dispatcher is an automation surface by design. - headers.add(AUTOMATION_HEADER, "true"); // A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so // without this RestTemplate would use urlencoded instead of the multipart the controller diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index 98e0e8ca2..ea5f95d77 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -31,22 +31,6 @@ class ApplicationPropertiesLogicTest { assertTrue(sys.isAnalyticsEnabled()); } - @Test - void storageSigning_userListScope_defaultsToOrg_andIsSettable() { - // Self-host backward-compat: scope must default to "org" (saas profile pins "team"). - ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing(); - - assertFalse(signing.isEnabled()); - assertEquals("org", signing.getUserListScope()); - - signing.setUserListScope("team"); - assertEquals("team", signing.getUserListScope()); - - // Reachable from the full tree as storage.signing.userListScope. - assertEquals( - "org", new ApplicationProperties().getStorage().getSigning().getUserListScope()); - } - @Test void tempFileManagement_defaults_and_overrides() { Function normalize = s -> Path.of(s).normalize().toString(); diff --git a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java index 7bab14188..06543e47b 100644 --- a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java +++ b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java @@ -59,53 +59,6 @@ class InternalApiClientTest { servletContext, userService, tempFileManager, environment, applicationProperties); } - @Test - void postTagsRequestAsAutomation() throws Exception { - // Every InternalApiClient.post() caller is a parent automation flow dispatching a child - // tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means - // the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of - // the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run - // bills as AUTOMATION, not AI. The header value is the literal string "true" because the - // interceptor compares case-insensitively-trimmed against that token. - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("fileInput", namedResource("input.pdf", "data")); - - Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp"); - TempFile tempFile = mock(TempFile.class); - when(tempFile.getPath()).thenReturn(tempPath); - when(tempFile.getFile()).thenReturn(tempPath.toFile()); - when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile); - - HttpHeaders[] captured = {null}; - - try (var ignored = - mockConstruction( - RestTemplate.class, - (rt, ctx) -> { - when(rt.httpEntityCallback(any(), eq(Resource.class))) - .thenAnswer( - inv -> { - HttpEntity entity = inv.getArgument(0); - captured[0] = entity.getHeaders(); - return (RequestCallback) req -> {}; - }); - when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any())) - .thenAnswer(inv -> fakeOkResponse(inv.getArgument(3))); - })) { - - InternalApiClient mockedClient = newClient(); - mockedClient.post("/api/v1/general/merge-pdfs", body); - - assertNotNull(captured[0]); - assertEquals( - "true", - captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER), - "Sub-step dispatch must carry the automation marker header"); - } finally { - Files.deleteIfExists(tempPath); - } - } - @Test void postDoesNotForceContentType() throws Exception { MultiValueMap body = new LinkedMultiValueMap<>(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 9bce7d1c5..2ab074d59 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -290,7 +290,6 @@ storage: linkExpirationDays: 3 # Number of days before share links expire signing: enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA] - userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only. autoPipeline: outputFolder: "" # Output folder for processed pipeline files (leave empty for default) fileReadiness: @@ -391,9 +390,6 @@ mcp: jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration. resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp). # Required: tokens must list this id in `aud` or the request is rejected. - acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707. - # For IdPs that cannot mint resource audiences (Supabase OAuth server always - # issues aud=authenticated) list that audience here, e.g. ['authenticated']. usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username') requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended) engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java index 9603cc780..7430776de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java @@ -1,9 +1,6 @@ package stirling.software.proprietary.mcp.security; -import java.util.Collection; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; @@ -11,35 +8,20 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; /** - * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one - * of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list - * exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server - * always issues {@code aud=authenticated}. Fails closed when nothing is configured. + * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its + * {@code aud} claim. Fails closed when the resource id is unset. */ public class McpAudienceValidator implements OAuth2TokenValidator { - private final Set acceptedAudiences; + private final String expectedResourceId; public McpAudienceValidator(String expectedResourceId) { - this(expectedResourceId, List.of()); - } - - public McpAudienceValidator(String expectedResourceId, Collection additionalAudiences) { - Set accepted = new LinkedHashSet<>(); - if (expectedResourceId != null && !expectedResourceId.isBlank()) { - accepted.add(expectedResourceId); - } - if (additionalAudiences != null) { - additionalAudiences.stream() - .filter(a -> a != null && !a.isBlank()) - .forEach(accepted::add); - } - this.acceptedAudiences = accepted; + this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId; } @Override public OAuth2TokenValidatorResult validate(Jwt token) { - if (acceptedAudiences.isEmpty()) { + if (expectedResourceId.isBlank()) { return OAuth2TokenValidatorResult.failure( new OAuth2Error( "invalid_token", @@ -48,13 +30,12 @@ public class McpAudienceValidator implements OAuth2TokenValidator { null)); } List aud = token.getAudience(); - if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) { + if (aud == null || !aud.contains(expectedResourceId)) { return OAuth2TokenValidatorResult.failure( new OAuth2Error( "invalid_token", - "Token audience does not include this server's resource id or an" - + " accepted audience (" - + String.join(", ", acceptedAudiences) + "Token audience does not include this server's resource id (" + + expectedResourceId + ").", null)); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java index 8148ce90a..ab6341ed3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java @@ -24,7 +24,6 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.OAuth2ProtectedResourceMetadata; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; @@ -158,11 +157,7 @@ public class McpSecurityConfig { throws Exception { String metadataPath = "/.well-known/oauth-protected-resource"; applyCors(http); - // RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known - // segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim - // the subpaths too; otherwise they fall through to another filter chain whose default - // Spring Security metadata filter serves a document without authorization_servers. - http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**") + http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath) // CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server // authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no // session, no form submissions; CSRF requires browser-attached ambient credentials @@ -173,8 +168,7 @@ public class McpSecurityConfig { .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests( a -> - a.requestMatchers( - HttpMethod.GET, metadataPath, metadataPath + "/**") + a.requestMatchers(HttpMethod.GET, metadataPath) .permitAll() .anyRequest() .authenticated()) @@ -193,18 +187,28 @@ public class McpSecurityConfig { .oauth2ResourceServer( oauth2 -> oauth2.authenticationEntryPoint( - // Advertise the path-inserted form; RFC 9728 makes - // it the canonical location for a resource with a - // path component. - new McpAuthenticationEntryPoint( - metadataPath + BASE_PATH)) + new McpAuthenticationEntryPoint(metadataPath)) // RFC 9728 protected-resource metadata for OAuth discovery. .protectedResourceMetadata( prm -> prm.protectedResourceMetadataCustomizer( - builder -> - buildResourceMetadata( - builder, auth))) + builder -> { + if (!auth.getResourceId() + .isBlank()) { + builder.resource( + auth + .getResourceId()); + } + if (!auth.getIssuerUri() + .isBlank()) { + builder.authorizationServer( + auth + .getIssuerUri()); + } + builder.scope("mcp.tools.read"); + builder.scope( + "mcp.tools.write"); + })) .jwt( jwt -> jwt.decoder(mcpJwtDecoder) @@ -213,25 +217,6 @@ public class McpSecurityConfig { return http.build(); } - /** Populate the RFC 9728 protected-resource metadata document from the configured auth. */ - private void buildResourceMetadata( - OAuth2ProtectedResourceMetadata.Builder builder, ApplicationProperties.Mcp.Auth auth) { - if (!auth.getResourceId().isBlank()) { - builder.resource(auth.getResourceId()); - } - if (!auth.getIssuerUri().isBlank()) { - builder.authorizationServer(auth.getIssuerUri()); - } - // Only advertise the granular tool scopes when we actually enforce them. When scopes are - // disabled (e.g. the IdP only mints coarse tokens, like Supabase), advertising scopes the - // authorization server can't issue makes spec-compliant clients request them and get - // rejected with invalid_request. - if (applicationProperties.getMcp().isScopesEnabled()) { - builder.scope("mcp.tools.read"); - builder.scope("mcp.tools.write"); - } - } - @Bean JwtDecoder mcpJwtDecoder() { ApplicationProperties.Mcp.Auth auth = applicationProperties.getMcp().getAuth(); @@ -251,9 +236,7 @@ public class McpSecurityConfig { JwtValidators.createDefaultWithIssuer(auth.getIssuerUri()); OAuth2TokenValidator combined = new DelegatingOAuth2TokenValidator<>( - defaultValidators, - new McpAudienceValidator( - auth.getResourceId(), auth.getAcceptedAudiences())); + defaultValidators, new McpAudienceValidator(auth.getResourceId())); decoder.setJwtValidator(combined); return decoder; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java index 2f088f444..8f0fc631b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResponse.java @@ -105,19 +105,4 @@ public class AiWorkflowResponse { + " body or via the X-Stirling-Tool-Report header. May be null for tools" + " that produce only a file.") private JsonNode report; - - @Schema( - description = - "Structured error code when a downstream tool call was blocked (e.g." - + " PAYG_LIMIT_REACHED). Lets the client react — such as opening the" - + " usage-limit modal — instead of only seeing a generic failure. Null" - + " for ordinary outcomes.") - private String errorCode; - - @Schema( - description = - "Whether the team is subscribed, carried from a downstream usage-limit response." - + " Selects which limit modal the client shows (free → subscribe," - + " subscribed → raise cap). Null when the downstream body omitted it.") - private Boolean errorSubscribed; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java deleted file mode 100644 index a49c8e5aa..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java +++ /dev/null @@ -1,41 +0,0 @@ -package stirling.software.proprietary.policy.config; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; - -/** - * Default (non-SaaS) policy context: a global admin may edit policies; scoping uses the current - * user's team (typically a single shared team self-hosted). SaaS overrides this with a team-leader - * check (see the {@code saas}-profiled implementation). - */ -@Component -@Profile("!saas") -@RequiredArgsConstructor -public class AdminPolicyManagementAuthority implements PolicyManagementAuthority { - - private final UserService userService; - - @Override - public boolean canEditPolicies() { - return userService.isCurrentUserAdmin(); - } - - @Override - public Long currentUserTeamId() { - String username = userService.getCurrentUsername(); - if (username == null) { - return null; - } - return userService - .findByUsername(username) - .map(User::getTeam) - .map(Team::getId) - .orElse(null); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index 85d918d1e..3abf919f1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -13,18 +13,26 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; /** - * Authority on which filesystem locations a policy may read/write. Checked at save time and again - * at run time, fail-closed in order: + * The single authority on which filesystem locations a policy may read from or write to. Folder + * sources and sinks take a configured directory, so without this a user who can save a policy could + * point one at Stirling's own config/secrets directory and exfiltrate (or overwrite) it. Every + * folder source and sink runs its directory through {@link #requirePermitted(Path)} at save time + * and again at run time. * - *
    - *
  1. denied entirely under the {@code saas} profile; - *
  2. Stirling's own config dir always rejected, even if an allowed root were misconfigured to - * contain it; - *
  3. must resolve within {@code policies.allowedFolderRoots}; none configured means all denied. - *
+ *

Enforced fail-closed, in order: * - *

Compared after normalisation so {@code ..} cannot escape a root. Symlink escape is not - * defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted. + *

    + *
  • Disabled in SaaS - folder access is never allowed when the {@code saas} profile is + * active; a tenant must not reach the host filesystem at all. + *
  • Protected paths - Stirling's own config directory (settings, database, keys, + * backups) is always rejected, even if an allowed root were misconfigured to contain it. + *
  • Allowlist - the directory must resolve within one of {@code + * policies.allowedFolderRoots}; with none configured, all folder access is refused. + *
+ * + *

Paths are compared after normalisation, so {@code ..} segments cannot walk out of an allowed + * root. (Symlink escape is not defended here; an operator who configures an allowed root containing + * a symlink to a sensitive location is trusted.) */ @Component public class FolderAccessGuard { @@ -42,7 +50,13 @@ public class FolderAccessGuard { this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); } - /** Returns the normalised absolute path; throws if not permitted. */ + /** + * Check that {@code dir} is a permitted folder location, returning its normalised absolute + * form. + * + * @throws IllegalArgumentException if folder access is disabled (SaaS or no roots configured), + * the path is inside a protected directory, or it falls outside every allowed root + */ public Path requirePermitted(Path dir) { if (saasActive) { throw new IllegalArgumentException( @@ -67,7 +81,7 @@ public class FolderAccessGuard { return normalized; } - /** Whether this policy touches a folder source/sink, and so is subject to these rules. */ + /** Whether this policy reads from or writes to a folder, and so is subject to these rules. */ public boolean usesFolderAccess(Policy policy) { boolean readsFolder = policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type())); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java deleted file mode 100644 index 1fcdbd505..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ /dev/null @@ -1,60 +0,0 @@ -package stirling.software.proprietary.policy.config; - -import java.util.List; -import java.util.Objects; - -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.service.UserServiceInterface; -import stirling.software.proprietary.policy.model.Policy; - -/** - * Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging - * to their own team (the team a policy is stamped with at creation). This binds everyone — admins - * included — so no one sees or touches another team's policies. Whether a user may edit - * (vs only view/run) is a separate check gated at the controller ({@code - * PolicyController#requirePolicyEditingAllowed} → team leader). Enforced only when login is - * enabled; single-user deployments (login disabled) pass every check. - */ -@Component -@RequiredArgsConstructor -public class PolicyAccessGuard { - - private final UserServiceInterface userService; - private final ApplicationProperties applicationProperties; - private final PolicyManagementAuthority policyManagementAuthority; - - /** Owner for a new policy: the current user, or {@code null} when login is disabled. */ - public String ownerForNewPolicy() { - return enforced() ? userService.getCurrentUsername() : null; - } - - /** Team a new policy is stamped with — the creator's team. {@code null} when login disabled. */ - public Long teamForNewPolicy() { - return enforced() ? policyManagementAuthority.currentUserTeamId() : null; - } - - /** Whether the policy belongs to the current user's team (so they may view/run/edit it). */ - public boolean canAccess(Policy policy) { - if (!enforced()) { - return true; - } - return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId()); - } - - /** The subset of {@code policies} scoped to the current user's team. */ - public List visible(List policies) { - if (!enforced()) { - return policies; - } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList(); - } - - private boolean enforced() { - return applicationProperties.getSecurity().isEnableLogin(); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java deleted file mode 100644 index 0ea3c298a..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java +++ /dev/null @@ -1,22 +0,0 @@ -package stirling.software.proprietary.policy.config; - -/** - * The current user's policy context, pluggable per deployment so the policy layer (proprietary) - * needn't know the team model. SaaS: a user may edit policies only if they lead their team, and - * every user is scoped to their own team. Self-hosted: a global admin may edit, scoped to their - * (typically single) team. Policies are isolated per team — nobody, admins included, sees or edits - * another team's policies. - */ -public interface PolicyManagementAuthority { - - /** Whether the current user may create, edit, or delete policies (for their own team). */ - boolean canEditPolicies(); - - /** - * The team that scopes the current user's policies — the team a new policy is stamped with and - * the only team whose policies the user may see/run/edit. {@code null} when it can't be - * resolved (e.g. login disabled / no team), in which case access falls back to the unteamed - * ({@code null}-team) policies. - */ - Long currentUserTeamId(); -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/NamedAsset.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/NamedAsset.java deleted file mode 100644 index 6886a9294..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/NamedAsset.java +++ /dev/null @@ -1,29 +0,0 @@ -package stirling.software.proprietary.policy.controller; - -import org.springframework.web.multipart.MultipartFile; - -import io.swagger.v3.oas.annotations.media.Schema; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -import lombok.Data; - -/** - * A supporting file paired with the asset key a pipeline step references from its {@code - * fileParameters}. The same key may appear on more than one asset to supply multiple files. - */ -@Data -@Schema(description = "A supporting file bound to the asset key a pipeline step references") -public class NamedAsset { - - @NotBlank - @Schema( - description = "Asset key referenced by a step's fileParameters", - example = "company-logo") - private String key; - - @NotNull - @Schema(description = "The supporting file", format = "binary") - private MultipartFile file; -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index a28fb47a4..df65fbd99 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -11,16 +11,17 @@ import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -29,17 +30,15 @@ import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; +import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; -import stirling.software.proprietary.policy.config.PolicyAccessGuard; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; @@ -52,15 +51,25 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.model.PolicyRunView; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.security.config.PremiumEndpoint; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** - * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code - * GET /run/{runId}} for status, download outputs via {@code GET /api/v1/general/files/{fileId}}. + * Manages policies and runs pipelines. The premium backend entry point: CRUD for stored {@code + * Policy} objects, running a stored policy by id, and running an ad-hoc pipeline (for AI/Automate + * one-offs). + * + *

Runs execute asynchronously and return a run id immediately. Poll {@code GET /run/{runId}} for + * status, and download outputs via the existing {@code GET /api/v1/general/files/{fileId}} using + * the file ids in the run view. */ @Slf4j @RestController @RequestMapping("/api/v1/policies") @Hidden +@PremiumEndpoint @RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") public class PolicyController { @@ -69,9 +78,10 @@ public class PolicyController { private final PolicyRunRegistry runRegistry; private final PolicyStore policyStore; private final PolicyValidator policyValidator; - private final PolicyAccessGuard policyAccessGuard; - private final PolicyManagementAuthority policyManagementAuthority; + private final FolderAccessGuard folderAccessGuard; + private final UserServiceInterface userService; private final ApplicationProperties applicationProperties; + private final ObjectMapper objectMapper; private final TempFileManager tempFileManager; @PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -79,16 +89,15 @@ public class PolicyController { summary = "Run a tool pipeline", description = "Accepts the documents to process (multipart field 'fileInput'), any supporting" - + " files (under 'assets[i].key' / 'assets[i].file'), and the pipeline" - + " definition as an application/json part named 'json'. Runs the steps" - + " in order asynchronously and returns a run id. Poll the run status" - + " endpoint and download outputs via /api/v1/general/files/{id}.") + + " files (each under a multipart field named as its asset key, e.g." + + " 'company-logo'), and a JSON pipeline definition ('json'). Runs the" + + " steps in order asynchronously and returns a run id. Poll the run" + + " status endpoint and download outputs via /api/v1/general/files/{id}.") public ResponseEntity> run( - @RequestPart("json") PipelineDefinition definition, - @Valid @ModelAttribute PolicyRunFiles files) + @RequestParam("json") String json, MultipartHttpServletRequest request) throws IOException { - requireRunnable(definition); - PolicyInputs inputs = toInputs(files); + PipelineDefinition definition = parseDefinition(json); + PolicyInputs inputs = collectInputs(request); String runId = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId(); return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); @@ -102,19 +111,18 @@ public class PolicyController { + " starts and completes, then a terminal 'completed', 'failed'," + " 'cancelled', or 'waiting' event carrying the final run view.") public SseEmitter runStream( - @RequestPart("json") PipelineDefinition definition, - @Valid @ModelAttribute PolicyRunFiles files) + @RequestParam("json") String json, MultipartHttpServletRequest request) throws IOException { - requireRunnable(definition); - PolicyInputs inputs = toInputs(files); + PipelineDefinition definition = parseDefinition(json); + PolicyInputs inputs = collectInputs(request); SseEmitter emitter = new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs()); emitter.onError(e -> log.warn("Policy run SSE emitter error", e)); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter)); - // whenComplete runs on the worker thread after the run finishes, so the terminal event - // never races the step events. + // Close the stream with a terminal event once the run finishes. whenComplete runs on the + // engine's worker thread after the run is done, so this never races the step events. handle.completion() .whenComplete( (run, throwable) -> { @@ -151,80 +159,41 @@ public class PolicyController { description = "Stores a policy (trigger config + steps + output + metadata). A blank id is" + " assigned; returns the stored policy with its id.") - public ResponseEntity savePolicy(@RequestBody Policy policy) { - requirePolicyEditingAllowed(); - Policy owned = resolveOwnership(policy); + public ResponseEntity savePolicy(@RequestBody String json) { + Policy policy = parsePolicy(json); + requireAuthorizedForFolderAccess(policy); try { - policyValidator.validate(owned); + policyValidator.validate(policy); } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } - return ResponseEntity.ok(policyStore.save(owned)); + return ResponseEntity.ok(policyStore.save(policy)); } /** - * Assign owner + owning team server-side. Create stamps the current user and their team; update - * preserves the existing owner and team after verifying the policy belongs to the caller's team - * — so the client can neither forge ownership/team on create nor reach across teams on update - * (a policy in another team reads as not-found). + * A policy that reads from or writes to a server folder grants whoever saves it access to that + * path, so restrict it to administrators on multi-user deployments. Single-user deployments + * (login disabled, e.g. desktop) trust the local operator. The {@link FolderAccessGuard} still + * enforces SaaS-off and the path allowlist during validation regardless of who saves. */ - private Policy resolveOwnership(Policy incoming) { - String id = incoming.id(); - if (id != null && !id.isBlank()) { - Policy existing = policyStore.get(id).orElse(null); - if (existing != null) { - if (!policyAccessGuard.canAccess(existing)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No policy: " + id); - } - return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); - } + private void requireAuthorizedForFolderAccess(Policy policy) { + if (!folderAccessGuard.usesFolderAccess(policy)) { + return; } - return withOwnerAndTeam( - incoming, - policyAccessGuard.ownerForNewPolicy(), - policyAccessGuard.teamForNewPolicy()); - } - - private static Policy withOwnerAndTeam(Policy policy, String owner, Long teamId) { - return new Policy( - policy.id(), - policy.name(), - owner, - policy.enabled(), - policy.trigger(), - policy.sources(), - policy.steps(), - policy.output(), - teamId); - } - - /** - * Creating, editing, pausing/resuming, and deleting policies requires the editor role for the - * caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global - * admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by - * {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume - * re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two - * covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login - * disabled) have no such role, so they trust the local operator. The path allowlist for folder - * sources/outputs is enforced separately by {@link PolicyValidator} at validation time. - */ - private void requirePolicyEditingAllowed() { if (!applicationProperties.getSecurity().isEnableLogin()) { return; } - if (!policyManagementAuthority.canEditPolicies()) { + if (!userService.isCurrentUserAdmin()) { throw new ResponseStatusException( HttpStatus.FORBIDDEN, - "Policies may only be created or modified by a team leader"); + "Folder sources and outputs may only be configured by an administrator"); } } @GetMapping - @Operation( - summary = "List policies", - description = "Lists the policies belonging to the caller's team.") + @Operation(summary = "List policies") public List listPolicies() { - return policyAccessGuard.visible(policyStore.all()); + return policyStore.all(); } @GetMapping("/{policyId}") @@ -232,7 +201,6 @@ public class PolicyController { public ResponseEntity getPolicy(@PathVariable String policyId) { return policyStore .get(policyId) - .filter(policyAccessGuard::canAccess) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -240,14 +208,9 @@ public class PolicyController { @DeleteMapping("/{policyId}") @Operation(summary = "Delete a policy by id") public ResponseEntity deletePolicy(@PathVariable String policyId) { - requirePolicyEditingAllowed(); - // Scope to the caller's team: a policy in another team reads as not-found. - boolean accessible = - policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); - if (accessible && policyStore.delete(policyId)) { - return ResponseEntity.noContent().build(); - } - return ResponseEntity.notFound().build(); + return policyStore.delete(policyId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); } @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -255,52 +218,70 @@ public class PolicyController { summary = "Run a stored policy", description = "Runs the stored policy's pipeline on the supplied files (primary documents" - + " under 'fileInput', supporting files under 'assets[i].key' /" - + " 'assets[i].file'). Runs regardless of the policy's enabled flag," - + " which only gates automatic triggering. Returns a run id.") + + " under 'fileInput', supporting files under their asset-key fields)." + + " Runs regardless of the policy's enabled flag, which only gates" + + " automatic triggering. Returns a run id.") public ResponseEntity> runStoredPolicy( - @PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files) - throws IOException { + @PathVariable String policyId, MultipartHttpServletRequest request) throws IOException { Policy policy = policyStore .get(policyId) - .filter(policyAccessGuard::canAccess) .orElseThrow( () -> new ResponseStatusException( HttpStatus.NOT_FOUND, "No policy: " + policyId)); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = collectInputs(request); String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); } - private static void requireRunnable(PipelineDefinition definition) { + private Policy parsePolicy(String json) { + try { + return objectMapper.readValue(json, Policy.class); + } catch (JacksonException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid policy JSON"); + } + } + + private PipelineDefinition parseDefinition(String json) { + PipelineDefinition definition; + try { + definition = objectMapper.readValue(json, PipelineDefinition.class); + } catch (JacksonException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Invalid pipeline definition JSON"); + } if (definition.steps().isEmpty()) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Pipeline definition has no steps"); } + return definition; } /** - * Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the - * named supporting-file store, where each asset's {@code key} is the name a step references - * from its {@code fileParameters}. Assets sharing a key are grouped, so a key may carry several - * files. + * Split the multipart file parts into the primary document stream ("fileInput") and the named + * supporting-file store: every other file field becomes an asset keyed by its field name, which + * a step references from {@code fileParameters}. */ - private PolicyInputs toInputs(PolicyRunFiles files) throws IOException { - List primary = toResources(files.getFileInput()); + private PolicyInputs collectInputs(MultipartHttpServletRequest request) throws IOException { + MultiValueMap fileMap = request.getMultiFileMap(); + List primary = toResources(fileMap.get("fileInput")); Map> supportingFiles = new LinkedHashMap<>(); - for (NamedAsset asset : files.getAssets()) { - Resource resource = toResource(asset.getFile()); - if (resource != null) { - supportingFiles - .computeIfAbsent(asset.getKey(), key -> new ArrayList<>()) - .add(resource); + for (Map.Entry> entry : fileMap.entrySet()) { + if ("fileInput".equals(entry.getKey())) { + continue; + } + List assets = toResources(entry.getValue()); + if (!assets.isEmpty()) { + supportingFiles.put(entry.getKey(), assets); } } return new PolicyInputs(primary, supportingFiles); } + /** + * A progress listener that forwards each step transition to the SSE stream as a "step" event. + */ private PolicyProgressListener streamListener(SseEmitter emitter) { return new PolicyProgressListener() { @Override @@ -339,8 +320,8 @@ public class PolicyController { try { emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON)); } catch (IOException | IllegalStateException e) { - // Client gone or emitter closed. The run continues and outputs stay downloadable via - // the job endpoints. + // Client disconnected or the emitter already closed. The run continues and its results + // remain downloadable via the job endpoints; nothing useful left to stream. log.debug("Dropping policy SSE event '{}': {}", name, e.getMessage()); } } @@ -351,27 +332,20 @@ public class PolicyController { return resources; } for (MultipartFile file : files) { - Resource resource = toResource(file); - if (resource != null) { - resources.add(resource); + if (file == null || file.isEmpty()) { + continue; } + TempFile tempFile = tempFileManager.createManagedTempFile("policy-run"); + file.transferTo(tempFile.getPath()); + final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename()); + resources.add( + new FileSystemResource(tempFile.getFile()) { + @Override + public String getFilename() { + return originalName; + } + }); } return resources; } - - /** Spool a single uploaded file to a managed temp file, preserving its name; null if empty. */ - private Resource toResource(MultipartFile file) throws IOException { - if (file == null || file.isEmpty()) { - return null; - } - TempFile tempFile = tempFileManager.createManagedTempFile("policy-run"); - file.transferTo(tempFile.getPath()); - final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename()); - return new FileSystemResource(tempFile.getFile()) { - @Override - public String getFilename() { - return originalName; - } - }; - } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java deleted file mode 100644 index f42e02a07..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunFiles.java +++ /dev/null @@ -1,32 +0,0 @@ -package stirling.software.proprietary.policy.controller; - -import java.util.ArrayList; -import java.util.List; - -import org.springframework.web.multipart.MultipartFile; - -import io.swagger.v3.oas.annotations.media.Schema; - -import jakarta.validation.Valid; - -import lombok.Data; - -/** - * The files supplied to a policy run: the primary documents and any keyed supporting assets. Bound - * from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as - * a separate typed {@code json} part. - * - *

Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code - * assets[i].file} for each supporting asset. - */ -@Data -@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets") -public class PolicyRunFiles { - - @Schema(description = "Primary input documents", format = "binary") - private List fileInput = new ArrayList<>(); - - @Valid - @Schema(description = "Supporting files, each bound to the asset key its step references") - private List assets = new ArrayList<>(); -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 7db5e7a14..8f41209c7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -8,13 +8,9 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import org.slf4j.MDC; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClientResponseException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,7 +23,6 @@ import stirling.software.common.service.JobQueue; import stirling.software.common.service.ResourceMonitor; import stirling.software.common.service.TaskManager; import stirling.software.common.util.ExecutorFactory; -import stirling.software.common.util.JobContext; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; @@ -36,27 +31,33 @@ import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; -import stirling.software.proprietary.service.DownstreamEntitlementError; /** - * Runs pipelines asynchronously as tracked jobs. {@link #submit} returns a run id immediately; the - * pipeline runs on a virtual thread (so a step blocked on a slow tool does not hold a platform - * thread). Drives {@link PolicyExecutor} for the step loop, projects status/outputs into {@link - * TaskManager} (existing job endpoints work unchanged), and keeps live state in {@link - * PolicyRunRegistry}. + * Runs pipelines asynchronously as tracked jobs. * - *

Manages its own virtual-thread execution rather than {@code JobExecutorService}, which - * force-completes a job once its work returns: incompatible with a run that suspends in {@code - * WAITING_FOR_INPUT}. Still applies the shared {@link ResourceMonitor}/{@link JobQueue} admission - * control so heavy runs queue under load. + *

Each run is the unit of async work: {@link #submit} returns a run id immediately and the + * pipeline executes on a virtual thread, so a step blocking on a slow tool does not tie up a + * platform thread. The run drives {@link PolicyExecutor} for the actual step loop, registers its + * outputs and progress with {@link TaskManager} (so the existing job status/download endpoints work + * unchanged), and keeps rich state in {@link PolicyRunRegistry}. + * + *

The engine deliberately manages its own virtual-thread execution rather than routing through + * {@code JobExecutorService}: that path force-completes a job once its work returns, which is + * incompatible with a run that suspends in {@code WAITING_FOR_INPUT}. It still applies the shared + * {@link ResourceMonitor}/{@link JobQueue} admission control, so heavy runs queue under load + * instead of oversubscribing. */ @Slf4j @Service @RequiredArgsConstructor public class PolicyEngine { - // Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate - // files. See ResourceMonitor#shouldQueueJob(int). + /** + * Resource weight of a pipeline run for admission control. A run chains many tools and holds + * intermediate files, so it is weighted as heavy work: the shared {@link ResourceMonitor} + * should let it start while the system is healthy but hold it back under memory/CPU pressure. + * See {@link ResourceMonitor#shouldQueueJob(int)} for how a weight maps to that decision. + */ private static final int RUN_RESOURCE_WEIGHT = 50; private final PolicyExecutor stepExecutor; @@ -71,65 +72,27 @@ public class PolicyEngine { private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); /** - * Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job - * (status/notes/results observable via the job endpoints); its future resolves when the run - * reaches a terminal or paused state. + * Submit a pipeline to run asynchronously. The returned handle's run id scopes a job in {@link + * TaskManager}, so progress (notes), status, and result files are observable via the existing + * job endpoints as well as via {@link #getRun(String)}; its completion future resolves when the + * run reaches a terminal or paused state. */ public PolicyRunHandle submit( PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) { - // Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them - // too. - // Capture the principal on this (request) thread — it does not survive the hop onto the - // async - // worker. - String principal = currentActingPrincipal(); - return submitForPrincipal(principal, principal, definition, inputs, listener); - } - - /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ - public PolicyRunHandle runPolicy( - Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { - // Bill the policy owner: trigger-fired runs have no security context, and the async worker - // doesn't inherit the caller's, so the owner (stamped at policy creation) is the reliable - // billing identity — and for org-wide policies the org/owner is meant to pay. But own the - // OUTPUT files as the user who triggered the run (captured here on the request thread) so - // they can download their enforced file; otherwise an org-wide policy's output is owned by - // the admin and the triggering user is denied it. Trigger-fired runs have no such user, so - // the owner owns those outputs. - String triggeringUser = currentActingPrincipal(); - String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); - return submitForPrincipal( - policy.owner(), fileOwner, policy.toDefinition(), inputs, listener); - } - - private PolicyRunHandle submitForPrincipal( - String billingPrincipal, - String fileOwner, - PipelineDefinition definition, - PolicyInputs inputs, - PolicyProgressListener listener) { - // Scope the run id to the current user (this request thread) so the file-download - // ownership check passes. No-op when security is off. + // Scope the run id to the current user (on this request thread) so the file-download + // ownership check passes; NoOpJobOwnershipService returns the id unchanged when security + // is off. String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString()); taskManager.createTask(runId); PolicyRun run = new PolicyRun(runId, definition); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); - // Re-establish the acting principal as the audit principal on the worker thread. Each tool - // step dispatches via InternalApiClient, which resolves the caller from - // UserService.getCurrentUsername() — that has an MDC `auditPrincipal` fallback for async - // threads. Without this the worker has no identity, tool calls fall back to the - // INTERNAL_API_USER, and PAYG charges that system account instead of the owner's team. - Runnable task = - () -> - runAsPrincipal( - billingPrincipal, - fileOwner, - () -> runToCompletion(run, inputs, tracking, completion)); + Runnable task = () -> runToCompletion(run, inputs, tracking, completion); - // One admission unit per run; steps run synchronously within it, so this gates heavy work - // without the pool-within-pool risk of queueing each tool call. + // Each run is one admission unit; steps run synchronously within it, so this gates heavy + // work under load without the pool-within-pool risk of queueing each tool call. Under + // resource pressure the run waits in the shared JobQueue; otherwise it starts immediately. if (resourceMonitor.shouldQueueJob(RUN_RESOURCE_WEIGHT)) { log.debug("Queueing policy run {} under resource pressure", runId); jobQueue.queueJob( @@ -147,12 +110,22 @@ public class PolicyEngine { return new PolicyRunHandle(runId, completion); } + /** + * Run a stored policy on demand. Builds the policy's pipeline and submits it. {@code enabled} + * gates automatic triggering, not explicit runs, so this runs regardless of that flag. + */ + public PolicyRunHandle runPolicy( + Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { + return submit(policy.toDefinition(), inputs, listener); + } + public PolicyRun getRun(String runId) { return registry.get(runId); } /** - * Mark a run cancelled if not already finished. Does not yet interrupt an in-flight tool call. + * Request cancellation of a run. Stage 1 marks the run cancelled in the registry if it has not + * already finished; interrupting an in-flight tool call lands in a later stage. */ public boolean cancel(String runId) { PolicyRun run = registry.get(runId); @@ -166,7 +139,10 @@ public class PolicyEngine { return cancelled; } - /** Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented. */ + /** + * Resume a run paused in {@code WAITING_FOR_INPUT}. Not yet implemented; the run shape and + * {@link WaitState} snapshot are in place so this can be added without reworking the engine. + */ public String resume(String runId, List additionalInputs) { throw new UnsupportedOperationException("Pause/resume is not yet implemented"); } @@ -187,8 +163,8 @@ public class PolicyEngine { taskManager.setComplete(runId); run.complete(outputs); } catch (PolicyInputRequiredException e) { - // Expected path: suspend rather than fail. Persist intermediates as fileIds so the run - // can resume after this worker thread is gone. + // Designed-for path: suspend the run rather than fail it. Persist intermediates as + // fileIds so the run can resume after this worker thread is gone. WaitState wait = suspend(e); run.waitForInput(wait); taskManager.addNote(runId, "Waiting for input: " + e.getMessage()); @@ -201,41 +177,21 @@ public class PolicyEngine { e.getMessage()); run.fail(message); taskManager.setError(runId, message); - } catch (RestClientResponseException e) { - // A downstream tool call returned an error status. When it's a structured entitlement - // response (401/402 with a JSON `error` sentinel), surface that code onto the run so - // the - // client can react — e.g. pop the usage-limit modal — instead of only seeing a generic - // failure. We don't interpret the code here (that would couple this module to the saas - // billing layer); we just pass it through for the client to map. Other statuses fall - // through to the generic failure below. - String code = DownstreamEntitlementError.extractCode(e); - if (code != null) { - log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); - String message = "Usage limit reached"; - run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e)); - taskManager.setError(runId, message); - } else { - String message = "Policy run failed: " + e.getMessage(); - log.error("Policy run {} failed (downstream HTTP error)", runId, e); - run.fail(message); - taskManager.setError(runId, message); - } } catch (Exception e) { String message = "Policy run failed: " + e.getMessage(); log.error("Policy run {} failed", runId, e); run.fail(message); taskManager.setError(runId, message); } finally { - // Always resolve so stream/await callers unblock. + // Always resolve the handle with the run's final state so stream/await callers unblock. completion.complete(run); } } private ResponseEntity failRejectedRun( PolicyRun run, CompletableFuture completion, Throwable ex) { - // Only reached if the run never started (e.g. queue full); a started run resolves its own - // completion in runToCompletion. + // Only reached if the run never started (e.g. the queue was full). A run that started + // always resolves its own completion in runToCompletion. if (!completion.isDone()) { String message = "Policy run could not be queued: " + ex.getMessage(); log.error("Policy run {} was not admitted: {}", run.getRunId(), ex.getMessage()); @@ -302,61 +258,4 @@ public class PolicyEngine { "The %s tool did not respond within %d seconds and was aborted.", e.getEndpointPath(), e.getReadTimeout().toSeconds()); } - - /** - * MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the - * controller audit aspect on request threads). We reuse it to carry the billing identity onto - * the policy worker thread. - */ - private static final String AUDIT_PRINCIPAL_MDC_KEY = "auditPrincipal"; - - /** - * The username to bill an ad-hoc run to, captured on the submitting (request) thread. Prefers - * the audit principal the controller aspect already stamped; falls back to the security context - * name. {@code anonymousUser} (and no identity) resolve to null so we don't try to bill it. - */ - private static String currentActingPrincipal() { - String mdc = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); - if (mdc != null && !mdc.isBlank()) { - return mdc; - } - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null) { - return null; - } - String name = auth.getName(); - return "anonymousUser".equals(name) ? null : name; - } - - /** - * Run {@code body} with {@code principal} set as the audit principal in MDC, so async tool - * dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is. - * Restores the previous MDC value afterward (defensive — worker threads aren't pooled). - */ - private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) { - // Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are - // set - // independently: usage is charged to billingPrincipal, but stored output files are owned by - // fileOwner — the user who triggered an org-wide policy — so they can fetch their results. - // Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only - // when present and restored afterward (defensive — worker threads aren't pooled). - String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); - String previousOwner = JobContext.getOwner(); - if (billingPrincipal != null && !billingPrincipal.isBlank()) { - MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal); - } - if (fileOwner != null && !fileOwner.isBlank()) { - JobContext.setOwner(fileOwner); - } - try { - body.run(); - } finally { - if (previousPrincipal != null) { - MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previousPrincipal); - } else { - MDC.remove(AUDIT_PRINCIPAL_MDC_KEY); - } - JobContext.setOwner(previousOwner); - } - } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java index 8bfb78410..eb5f028bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java @@ -7,8 +7,11 @@ import org.springframework.core.io.Resource; import tools.jackson.databind.JsonNode; /** - * Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored). - * {@code report}/{@code reportTool} carry the last step's structured report and its operation, or - * null if no step produced one. + * Result of running a pipeline through {@link PolicyExecutor}. + * + *

{@code files} are the final output resources (temp files, not yet stored to {@code + * FileStorage}). {@code report} is the structured metadata payload captured from the last step that + * produced one (a JSON body, or an {@code X-Stirling-Tool-Report} header), with {@code reportTool} + * naming the step it came from; both are null when no step produced a report. */ public record PolicyExecutionResult(List files, JsonNode report, String reportTool) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index af9f8c728..92a436bdf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -35,11 +35,15 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; /** - * Runs an ordered chain of tool steps, feeding each step's output files into the next. + * Runs an ordered chain of tool steps, chaining each step's output files into the next step's + * input. * - *

Steps dispatch synchronously via {@link InternalApiClient} loopback HTTP (each tool runs in - * its own handler, returns its file inline). The caller controls threading. Files cross step - * boundaries as {@link Resource} temp files and are only persisted at the run boundaries by the + *

This is the single execution loop for the proprietary surface (AI plans now; + * manually-triggered runs and watched folders later). Each step is dispatched synchronously via + * {@link InternalApiClient} loopback HTTP: the tool runs in its own handler and returns its file + * inline. The caller decides how to run the executor itself (the AI turn loop calls it directly; + * the engine runs it on a virtual thread for async runs). Files cross step boundaries as {@link + * Resource} temp files; they are only persisted to durable storage at the run boundaries by the * caller. */ @Slf4j @@ -54,16 +58,25 @@ public class PolicyExecutor { private final TempFileManager tempFileManager; private final ObjectMapper objectMapper; - // files: result files (one, or many for ZIP-response tools). report: optional structured - // payload the tool surfaced alongside or instead of a file. + /** + * Internal value-class for tool responses. {@code files} holds any result files (typically one; + * multiple for ZIP-response tools). {@code report} holds an optional structured metadata + * payload the tool chose to surface alongside (or instead of) a file. + */ private record ToolResult(List files, JsonNode report) {} /** - * Run every step in order, feeding each step's output into the next. Supporting files in {@code - * inputs} bind to named file fields and never enter the document stream. + * Execute every step in {@code definition} in order, feeding each step's output into the next. + * Supporting files supplied in {@code inputs} are bound to steps' named file fields and never + * enter the document stream. * + * @param definition the pipeline to run (must have at least one step) + * @param inputs the primary documents plus the named supporting-file store + * @param listener receives per-step progress + * @return the final output files plus the last structured report produced, if any * @throws InternalApiTimeoutException if a tool does not respond within its read timeout - * @throws IOException on a non-OK tool response, a missing supporting file, or a read failure + * @throws IOException if a tool returns a non-OK response, references a missing supporting + * file, or a file cannot be read */ public PolicyExecutionResult execute( PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) @@ -75,7 +88,7 @@ public class PolicyExecutor { List currentFiles = inputs.primary(); Map> supportingFiles = inputs.supportingFiles(); - // Last non-null report wins: the terminal step defines the output. + // Propagate the *last* non-null report; the terminal step defines the output. JsonNode lastReport = null; String lastReportTool = null; @@ -100,9 +113,13 @@ public class PolicyExecutor { } /** - * Multi-input endpoints get all files in one call; others are called once per file. ZIP - * responses are unpacked so each inner file is its own result (e.g. split). For per-file - * dispatch the first non-null report wins. + * Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one + * call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each + * inner file is treated as its own result (e.g. split outputs a ZIP of pages). + * + *

A structured {@code report} may be returned alongside (or instead of) files; see {@link + * ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first + * non-null report wins. */ private ToolResult executeStep( PipelineStep step, @@ -129,10 +146,17 @@ public class PolicyExecutor { } /** - * Call an endpoint, returning result files and optional report. Response handling: JSON body is - * the report with no file; a file body returns the file plus any {@link - * AiToolResponseHeaders#TOOL_REPORT} header report; ZIP responses (per tool metadata) are - * unpacked to a flat file list. + * Call an endpoint and return its result files and optional report. + * + *

    + *
  • JSON body (Content-Type: application/json): the entire body is the report, no files are + * returned. + *
  • File body (PDF etc.): the file is returned; if an {@link + * AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is + * parsed as the report. + *
  • ZIP responses declared by the tool metadata service are unpacked so callers always see + * a flat list of result files. + *
*/ private ToolResult callEndpoint( PipelineStep step, List files, Map> supportingFiles) @@ -142,8 +166,8 @@ public class PolicyExecutor { for (Resource file : files) { body.add("fileInput", file); } - // Bind supporting files to named tool fields (e.g. stampImage); from the asset store, not - // the document stream. + // Bind supporting files to their named tool fields (e.g. stampImage, overlayFiles). These + // come from the run's named asset store, not the document stream. for (Map.Entry binding : step.fileParameters().entrySet()) { String fieldName = binding.getKey(); String assetKey = binding.getValue(); @@ -165,9 +189,9 @@ public class PolicyExecutor { for (Map.Entry entry : step.parameters().entrySet()) { if (entry.getValue() instanceof List list) { if (containsStructuredElements(list)) { - // These endpoints (e.g. /security/redact redactions, /general/edit-text edits) - // bind a list of structured objects from a single JSON string field via a - // property editor, so pre-serialize the whole list. + // Endpoints binding lists of structured objects (e.g. /security/redact's + // redactions, /general/edit-text's edits) parse a single JSON string field via + // a property editor. Pre-serialize the whole list so binding succeeds. body.add(entry.getKey(), objectMapper.writeValueAsString(list)); } else { for (Object item : list) { @@ -185,8 +209,8 @@ public class PolicyExecutor { } Resource resource = response.getBody(); - // Filter ops return an empty body to mean "filtered out": drop it rather than forward a - // zero-byte document. + // Filter operations return an empty body to signal the file was filtered out: drop it + // rather than forwarding a zero-byte document. if (isFilterOperation(endpointPath) && isEmpty(resource)) { return new ToolResult(List.of(), null); } @@ -194,7 +218,7 @@ public class PolicyExecutor { HttpHeaders headers = response.getHeaders(); MediaType contentType = headers.getContentType(); - // JSON-only response: whole body is the report, no file. + // JSON-only response: the whole body is the structured report, no result file. if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { try (InputStream is = resource.getInputStream()) { JsonNode report = objectMapper.readTree(is); @@ -209,7 +233,10 @@ public class PolicyExecutor { return new ToolResult(List.of(resource), report); } - /** Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header, or null. */ + /** + * Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode}, + * or return null. + */ private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) { String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT); if (raw == null || raw.isBlank()) { @@ -237,7 +264,8 @@ public class PolicyExecutor { } /** - * Fail if any primary-stream file is a type the step rejects. No declared type means anything. + * Fail the run if any document in the primary stream is not a file type the step accepts. An + * endpoint that declares no specific input type accepts anything. */ private void requireAcceptedTypes(String operation, List files) throws IOException { List accepted = toolMetadataService.getExtensionTypes(false, operation); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java index 936dedbe9..fa30373c5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyInputRequiredException.java @@ -7,9 +7,15 @@ import org.springframework.core.io.Resource; import lombok.Getter; /** - * Thrown by a step that needs further user input, pausing the run in {@code WAITING_FOR_INPUT} - * instead of failing. Carries the resume reason, 0-based resume step index, and intermediate files; - * the engine persists those and suspends. Not yet thrown by any step. + * Thrown by a step to signal that the run cannot proceed without further user input, pausing the + * run in {@code WAITING_FOR_INPUT} rather than failing it. + * + *

Carries everything needed to resume: a human-readable reason, the 0-based index of the step to + * resume from, and the intermediate files produced so far. The engine persists those files and + * suspends the run. + * + *

Defined now to fix the run shape; no step throws it yet, and the resume handshake is + * implemented in a later stage. */ @Getter public class PolicyInputRequiredException extends RuntimeException { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java index 09deaa7b5..eeda863cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunHandle.java @@ -5,9 +5,12 @@ import java.util.concurrent.CompletableFuture; import stirling.software.proprietary.policy.model.PolicyRun; /** - * Returned by {@link PolicyEngine#submit}: the run id (status polling, result download) plus a - * future that resolves when the run reaches a terminal or paused state. The future carries the - * {@link PolicyRun} whose status describes the outcome; it does not complete exceptionally for - * ordinary run failures. + * Returned by {@link PolicyEngine#submit}: the run id (for status polling and result download) plus + * a future that resolves when the run reaches a terminal or paused state. + * + *

The completion future lets callers react to the end of a run (e.g. an SSE endpoint sending a + * final event and closing the stream) without polling. It carries the {@link PolicyRun} whose + * status describes the outcome (completed, failed, cancelled, or waiting for input); it does not + * complete exceptionally for ordinary run failures. */ public record PolicyRunHandle(String runId, CompletableFuture completion) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java index edc906461..acf5a3168 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java @@ -19,12 +19,16 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.PolicyRun; /** - * In-memory store of live {@link PolicyRun} state, keyed by runId. Authoritative run state machine; - * durable status/files are projected separately into {@code TaskManager}. + * In-memory store of live {@link PolicyRun} state, keyed by runId. Holds the authoritative run + * state machine; durable status/files for download are projected separately into {@code + * TaskManager}. * - *

A scheduled sweep evicts only terminal runs aged past {@code policies.runExpiryMinutes}; - * active and paused runs are kept regardless of age. Eviction frees only this map's entry: the - * shared {@code TaskManager} job owns file-lifecycle cleanup. + *

Finished runs are evicted on a fixed interval once they age past {@code + * policies.runExpiryMinutes}, mirroring the job-result expiry in {@code TaskManager} so a run's + * rich in-memory state does not outlive the process. Only terminal runs are evicted; active and + * paused ({@code WAITING_FOR_INPUT}) runs are retained regardless of age. Result files are not + * touched here: a run shares its runId with a {@code TaskManager} job, which owns file-lifecycle + * cleanup, so eviction only frees this map's entry. */ @Slf4j @Service @@ -57,7 +61,7 @@ public class PolicyRunRegistry { return runs.values(); } - /** Scheduled sweep entry point. */ + /** Scheduled hook: evict terminal runs that finished before the expiry window. */ private void evictExpiredRuns() { try { evictExpired(Instant.now().minus(runExpiry)); @@ -67,8 +71,9 @@ public class PolicyRunRegistry { } /** - * Evict terminal runs last updated before {@code cutoff}, returning the count. Package-visible - * so the sweep and tests share one path with an explicit cutoff. + * Remove every terminal run last updated before {@code cutoff}; active and paused runs are kept + * regardless of age. Returns the number evicted. Package-visible so the scheduled sweep and + * tests exercise the same path with an explicit cutoff. */ int evictExpired(Instant cutoff) { int removed = 0; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 2a59e91bb..8685bf020 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -20,8 +20,14 @@ import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; /** - * Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide when - * and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. + * Runs policies, and is the one place that knows how to turn a policy's configured {@link InputSpec + * sources} into actual runs. Triggers (schedule, and future webhook/folder-watch) decide + * when to run and call {@link #run(Policy)}; they never touch sources themselves. The + * controller uses the supplied-input and ad-hoc entry points for on-demand work. + * + *

This is the seam that keeps triggers and sources independent: a trigger depends on the runner, + * the runner depends on the {@link InputSource} beans, and a source depends on neither - it just + * yields {@link ResolvedInput units of work}, each carrying its own completion hook. */ @Slf4j @Service @@ -32,9 +38,10 @@ public class PolicyRunner { private final List inputSources; /** - * Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so - * one failure does not affect the others. No sources means one run with no input (generator - * pipeline). + * Run a policy by pulling from every source it configures: each source yields zero or more + * units of work, and each unit becomes its own run so one failure does not affect the others. A + * policy with no sources runs once with no input files (a generator pipeline). Used by + * automatic triggers. */ public void run(Policy policy) { List sources = policy.sources(); @@ -47,7 +54,11 @@ public class PolicyRunner { } } - /** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */ + /** + * Run a stored policy on files supplied directly by the caller (e.g. a manual run with + * uploads), bypassing its configured sources. Returns the run handle so callers can stream + * progress. + */ public PolicyRunHandle runWith( Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { return policyEngine.runPolicy(policy, inputs, listener); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index c4de3b207..2aa4d98be 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -15,9 +15,12 @@ import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** - * Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean - * that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger - * is a manual-only policy and skips trigger validation. + * Validates a policy's trigger, sources, and output configuration by delegating each facet to the + * bean that handles its type. Called when a policy is saved so a misconfigured schedule, missing + * folder directory, or unknown type fails fast instead of silently misbehaving at run time. + * + *

The trigger is optional (a {@code null} trigger is a manual-only policy and needs no + * validation); every configured source is validated. */ @Service @RequiredArgsConstructor @@ -28,7 +31,8 @@ public class PolicyValidator { private final List outputSinks; /** - * @throws IllegalArgumentException if any facet's type is unknown or its config is invalid + * @throws IllegalArgumentException if any facet's type is unknown or its configuration is + * invalid */ public void validate(Policy policy) { if (policy.trigger() != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index a6c61b654..d67820275 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -22,13 +22,21 @@ import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PolicyInputs; /** - * Reads input files from a directory; each ready file is its own unit of work so one failure does - * not affect the others. + * Reads input files from a directory. Each ready file becomes its own unit of work (one run per + * file) so a failure on one file does not affect the others. * - *

Mode option: "consume" (default) claims each file by moving it into {@code - * .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so - * each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness - * is checked first so files mid-write are skipped. + *

Two modes via the {@code mode} option: + * + *

    + *
  • {@code "consume"} (default) - claim each file by moving it into {@code + * .stirling/processing}, then route it to {@code .stirling/done} or {@code .stirling/error} + * when its run finishes. Each file is processed once; right for "process new arrivals" (and + * the basis of watched folders). + *
  • {@code "snapshot"} - read the directory's current files without moving them; every run sees + * the full set again. Right for "always regenerate from a fixed input set". + *
+ * + * Readiness is checked first (via {@link FileReadinessChecker}) so files mid-write are skipped. */ @Slf4j @Service @@ -36,7 +44,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; public class FolderInputSource implements InputSource { private static final String TYPE = FolderAccessGuard.FOLDER_TYPE; - // Bookkeeping lives under one hidden dir so the watched folder stays tidy. + // Bookkeeping lives under one hidden namespace dir so the watched folder stays tidy. private static final String WORK_SUBDIR = ".stirling"; private static final String PROCESSING_SUBDIR = "processing"; private static final String DONE_SUBDIR = "done"; @@ -99,7 +107,6 @@ public class FolderInputSource implements InputSource { return work; } - // Atomic move into processing/: only one sweep can win the claim, the rest see the file gone. private Path claim(Path inputDir, Path file) { try { Path processingDir = workDir(inputDir, PROCESSING_SUBDIR); @@ -128,6 +135,7 @@ public class FolderInputSource implements InputSource { } } + /** A bookkeeping subdirectory under the watched folder's {@code .stirling} namespace. */ private static Path workDir(Path inputDir, String subdir) { return inputDir.resolve(WORK_SUBDIR).resolve(subdir); } @@ -158,6 +166,7 @@ public class FolderInputSource implements InputSource { } } + /** The typed, validated form of a folder source's options: the directory and dedup mode. */ record FolderConfig(Path directory, boolean snapshot) { private static final String DIRECTORY_OPTION = "directory"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java index 436f6c526..a6f116d02 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java @@ -7,9 +7,14 @@ import java.util.List; import stirling.software.proprietary.policy.model.InputSpec; /** - * Resolves a policy {@link InputSpec} into the files to run on. Implementations are beans selected - * by {@link #supports(InputSpec)}, so a new source kind (folder, S3) is just a new bean. A manual - * run may supply files directly and bypass sources entirely. + * Resolves one of a policy's {@link InputSpec sources} into the files to run on - answering + * where a run's files come from, independent of when it runs. The counterpart of + * {@code PolicyOutputSink}: implementations are beans selected by {@link #supports(InputSpec)}, so + * a new source kind (folder, S3) is just a new bean. + * + *

Driven by the {@code PolicyRunner}, which a trigger calls when a policy is due; a source is + * passive and knows nothing about what triggered the run. A manual run may instead supply files + * directly and bypass sources entirely. */ public interface InputSource { @@ -19,18 +24,24 @@ public interface InputSource { /** Whether this source can handle the given spec. */ boolean supports(InputSpec spec); - /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */ + /** + * Check that an input spec is usable, throwing {@link IllegalArgumentException} if not. Called + * when a policy is saved so misconfiguration fails fast rather than at run time. + */ default void validate(InputSpec spec) {} /** - * Resolve the spec into zero or more units of work, each carrying one run's files and a - * completion hook. Empty list means nothing to run right now. + * Resolve the spec into zero or more units of work, each carrying the files for one run and a + * completion hook. Returning an empty list means there is nothing to run right now. */ List resolve(InputSpec spec) throws IOException; /** - * Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is - * still done by {@link #resolve}. Non-filesystem sources return empty and are not watchable. + * The local filesystem directories this source draws from, if any, for triggers that want to + * react to changes there (the folder-watch trigger) rather than poll. Advisory only: it merely + * tells a trigger where to watch; resolving the spec into files is still this source's + * job via {@link #resolve}. Non-filesystem sources (S3, ...) return an empty list and so are + * simply not watchable. Default: nothing to watch. */ default List watchTargets(InputSpec spec) { return List.of(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolvedInput.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolvedInput.java index b5d7de94f..285413436 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolvedInput.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolvedInput.java @@ -5,9 +5,10 @@ import java.util.function.Consumer; import stirling.software.proprietary.policy.model.PolicyInputs; /** - * One unit of work from an {@link InputSource}: the files to run plus a completion callback invoked - * with the run's success (e.g. a folder source routes the input to done/error). A source may return - * several of these, one per file. + * One unit of work produced by an {@link InputSource}: the files to run plus a completion callback + * invoked with the run's success once it finishes (e.g. a folder source routes the input to {@code + * .stirling/done} or {@code .stirling/error}). A source may return several of these (e.g. one per + * file). */ public record ResolvedInput(PolicyInputs inputs, Consumer onComplete) { @@ -15,7 +16,7 @@ public record ResolvedInput(PolicyInputs inputs, Consumer onComplete) { onComplete = onComplete == null ? success -> {} : onComplete; } - /** No completion side effect. */ + /** A unit of work with no completion side effect. */ public static ResolvedInput of(PolicyInputs inputs) { return new ResolvedInput(inputs, success -> {}); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/InputSpec.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/InputSpec.java index 181d5d78c..d6816c60f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/InputSpec.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/InputSpec.java @@ -3,8 +3,15 @@ package stirling.software.proprietary.policy.model; import java.util.Map; /** - * One input source for a policy. {@code type} keys an {@code InputSource} bean; a run pulls from - * every source. + * One source a policy's input files come from. {@code type} selects an {@code InputSource} + * ("folder", and in future "s3", ...); {@code options} carries source-specific configuration (a + * directory, a bucket, a dedup mode, ...). + * + *

A policy holds a list of these; a run pulls from every one. An empty list means the policy + * runs with no input files (a generator pipeline, or files supplied directly to a manual run). + * + *

Data-driven and parallel to {@link OutputSpec}/{@link TriggerConfig}: a new source kind is a + * new {@code type} handled by a new {@code InputSource} bean. */ public record InputSpec(String type, Map options) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/OutputSpec.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/OutputSpec.java index 1479ac643..3caab8afc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/OutputSpec.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/OutputSpec.java @@ -2,13 +2,19 @@ package stirling.software.proprietary.policy.model; import java.util.Map; -/** Where a run's outputs are delivered. {@code type} keys a {@code PolicyOutputSink} bean. */ +/** + * Describes where a pipeline run's output files should be delivered. {@code type} selects a {@code + * PolicyOutputSink} (e.g. "inline"); {@code options} carries sink-specific configuration. + * + *

New destinations (folder, S3) are added as new sink beans keyed on a new {@code type} without + * changing this shape or the engine. + */ public record OutputSpec(String type, Map options) { public OutputSpec { options = options == null ? Map.of() : options; } - /** Default sink: store outputs and return them to the caller for download. */ + /** The default destination: store outputs and return them to the caller for download. */ public static OutputSpec inline() { return new OutputSpec("inline", Map.of()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java index 146424b0f..983c3dbca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java @@ -3,10 +3,11 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * An ordered chain of tool steps plus an output destination; the unit the engine executes. + * An ordered chain of tool steps plus where the output should go. * - *

{@code output} may be null for callers that handle result files themselves (e.g. the AI - * workflow, which builds its own response payload). + *

This is the single shape executed by the policy engine, shared by AI plans, manually-triggered + * runs, and (later) watched folders. {@code output} may be null for callers that handle result + * files themselves (e.g. the AI workflow, which builds its own response payload). */ public record PipelineDefinition(String name, List steps, OutputSpec output) { public PipelineDefinition { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java index 99d888499..52c6c060a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineStep.java @@ -3,13 +3,17 @@ package stirling.software.proprietary.policy.model; import java.util.Map; /** - * A single tool invocation. {@code operation} is a Stirling endpoint path (e.g. {@code - * /api/v1/misc/compress-pdf}) per the {@code InternalApiClient} convention; {@code parameters} are - * scalar form fields. + * A single tool invocation in a pipeline: the API endpoint path to call and the inputs to pass. * - *

{@code fileParameters} maps a tool's named file field (e.g. {@code stampImage}, beyond the - * primary {@code fileInput} stream) to an asset key in the run's supporting-file store, keeping - * supporting inputs out of the document stream that flows step to step. + *

{@code operation} is a Stirling tool endpoint path (e.g. {@code /api/v1/misc/compress-pdf}), + * matching the dispatch convention used by {@code InternalApiClient}. {@code parameters} are the + * tool-specific scalar form fields. + * + *

{@code fileParameters} binds a tool's named file fields (beyond the primary {@code fileInput} + * stream) to supporting files supplied with the run: it maps the form field name (e.g. {@code + * stampImage}, {@code overlayFiles}) to an asset key in the run's supporting-file store. This keeps + * supporting inputs (a stamp image, a certificate, an overlay) out of the document stream that + * flows step to step. */ public record PipelineStep( String operation, Map parameters, Map fileParameters) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 6d5366eb4..14fd35b63 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -3,11 +3,16 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * A stored automation: ordered tool steps, input sources, and an output destination. + * A stored automation: an ordered chain of tool steps, the sources its input files come from, and + * an output destination for the results. * - *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code - * null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where - * files come from; a run pulls from every source. + *

Every policy can always be run on demand (manually). It may additionally carry one automatic + * {@link TriggerConfig} - usually a schedule - that fires it without a person asking; a {@code + * null} trigger means manual-only. A trigger decides when a run happens and a {@link + * InputSpec source} decides where its files come from; the two are independent, and a run + * pulls from every configured source. + * + *

This is the feature's central configuration object - what a user defines and the engine runs. */ public record Policy( String id, @@ -17,8 +22,7 @@ public record Policy( TriggerConfig trigger, List sources, List steps, - OutputSpec output, - Long teamId) { + OutputSpec output) { public Policy { sources = sources == null ? List.of() : List.copyOf(sources); @@ -26,22 +30,6 @@ public record Policy( output = output == null ? OutputSpec.inline() : output; } - /** - * Without an explicit owning team. Kept for the engine and tests; the controller always stamps - * a {@code teamId} on stored policies so they stay scoped to the creating user's team. - */ - public Policy( - String id, - String name, - String owner, - boolean enabled, - TriggerConfig trigger, - List sources, - List steps, - OutputSpec output) { - this(id, name, owner, enabled, trigger, sources, steps, output, null); - } - /** A policy with no configured sources (a generator, or files supplied directly to a run). */ public Policy( String id, @@ -51,10 +39,10 @@ public record Policy( TriggerConfig trigger, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output, null); + this(id, name, owner, enabled, trigger, List.of(), steps, output); } - /** This policy's pipeline as the engine sees it. */ + /** The engine-level, trigger-agnostic view of this policy's pipeline. */ public PipelineDefinition toDefinition() { return new PipelineDefinition(name, steps, output); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java index 7088cd441..4e6ddae83 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyInputs.java @@ -6,9 +6,17 @@ import java.util.Map; import org.springframework.core.io.Resource; /** - * A run's files. {@code primary} documents flow step to step; {@code supportingFiles} are auxiliary - * assets bound by key via {@link PipelineStep#fileParameters()} and never enter the document - * stream. Asset values are lists so one key can carry a multi-file field (e.g. attachments). + * The files a run operates on, split into two roles: + * + *

    + *
  • {@code primary} - the documents that flow through the pipeline, each step's output becoming + * the next step's input. + *
  • {@code supportingFiles} - a named store of auxiliary files (a stamp image, certificate, + * overlay, attachments) that steps bind to their named file fields via {@link + * PipelineStep#fileParameters()}. These never enter the document stream. + *
+ * + * Asset values are lists so a single key can carry multi-file fields (e.g. attachments). */ public record PolicyInputs(List primary, Map> supportingFiles) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index a24114eda..20621f547 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -8,10 +8,12 @@ import lombok.Getter; import stirling.software.common.model.job.ResultFile; /** - * Live, mutable state of one pipeline run, held in memory by {@code PolicyRunRegistry} and the - * authoritative source of the state machine. Carries execution state ({@code JobResult} does not - * model status/step cursor/wait state); also projected into {@code TaskManager} for cluster-visible - * status and download. + * Live, mutable state of a single pipeline run, held in memory by {@code PolicyRunRegistry}. + * + *

This carries the rich execution state (status, step cursor, wait state) that the job system's + * {@code JobResult} does not model. The run is also projected into {@code TaskManager} for + * cluster-visible status, progress notes, and file download; this object is the authoritative + * source of the state machine. */ @Getter public class PolicyRun { @@ -27,21 +29,6 @@ public class PolicyRun { private volatile WaitState waitState; private volatile String error; - - /** - * Stable, machine-readable failure code the client can branch on — e.g. an entitlement-limit - * sentinel ({@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED}) propagated from a downstream - * tool call's 402 — alongside the human-readable {@link #error}. Null unless set on failure. - */ - private volatile String errorCode; - - /** - * For an entitlement-limit failure, whether the team was subscribed (over its spending cap) vs - * un-subscribed (free allowance spent) — taken from the blocking 402 body. Drives which - * usage-limit modal the client shows. Null unless {@link #errorCode} is an entitlement code. - */ - private volatile Boolean errorSubscribed; - private volatile List outputs = List.of(); private volatile Instant updatedAt = Instant.now(); @@ -76,24 +63,15 @@ public class PolicyRun { touch(); } - /** - * Fail with a stable {@code errorCode} the client can branch on (e.g. an entitlement-limit - * sentinel from a downstream 402), plus the optional {@code subscribed} flag from that - * response, in addition to the human-readable message. - */ - public synchronized void failWithCode(String message, String errorCode, Boolean subscribed) { - this.errorCode = errorCode; - this.errorSubscribed = subscribed; - fail(message); - } - public synchronized void waitForInput(WaitState wait) { this.waitState = wait; this.status = PolicyRunStatus.WAITING_FOR_INPUT; touch(); } - /** Cancels unless already terminal; returns whether it transitioned. */ + /** + * Mark cancelled if the run has not already reached a terminal state. Returns whether it did. + */ public synchronized boolean cancel() { if (status.isTerminal()) { return false; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java index 646703bf5..ee75f5e50 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunStatus.java @@ -1,8 +1,11 @@ package stirling.software.proprietary.policy.model; /** - * Lifecycle states of a {@link PolicyRun}. {@code WAITING_FOR_INPUT} models a thread-free pause; - * the resume handshake lands in a later stage. + * Lifecycle states of a {@link PolicyRun}. + * + *

{@code WAITING_FOR_INPUT} is modelled now so the engine and run shape support pausing a run + * (e.g. a step that blocks for a human decision) without holding a thread; the resume handshake is + * implemented in a later stage. */ public enum PolicyRunStatus { PENDING, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java index 61a6c61f6..a3427835c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java @@ -5,8 +5,8 @@ import java.util.List; import stirling.software.common.model.job.ResultFile; /** - * Read-only view of a {@link PolicyRun} for the status endpoint. Outputs are {@link ResultFile}s, - * downloadable via {@code GET /api/v1/general/files/{id}}. + * Read-only view of a {@link PolicyRun} returned by the status endpoint. Output files are surfaced + * as {@link ResultFile} so the caller can download each via {@code GET /api/v1/general/files/{id}}. */ public record PolicyRunView( String runId, @@ -14,8 +14,6 @@ public record PolicyRunView( int currentStep, int stepCount, String error, - String errorCode, - Boolean errorSubscribed, List outputs) { public static PolicyRunView of(PolicyRun run) { @@ -25,8 +23,6 @@ public record PolicyRunView( run.getCurrentStep(), run.stepCount(), run.getError(), - run.getErrorCode(), - run.getErrorSubscribed(), run.getOutputs()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java index 7a684942f..148534636 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Schedule.java @@ -11,9 +11,16 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; /** - * A scheduled policy's firing cadence; {@code type} is the JSON discriminator. Wall-clock kinds - * ({@link Daily}, {@link Weekly}, {@link Monthly}) evaluate in the {@code after} argument's zone; - * {@link Every} is a fixed offset and ignores wall-clock time. + * When a scheduled policy should fire, expressed as intent ("every day at 02:00") rather than a + * cron string. A frontend builds one of these from a friendly picker; the schedule trigger asks it + * for the next firing after a given moment. + * + *

Sealed so every cadence is an explicit, self-validating shape and adding a new one forces a + * new subtype rather than another string convention to learn. The {@code type} discriminator stays + * on the wire so the frontend can switch on it without knowing the Java hierarchy. + * + *

Wall-clock kinds ({@link Daily}, {@link Weekly}, {@link Monthly}) are evaluated in the zone of + * the {@code after} argument; {@link Every} is a fixed offset and ignores wall-clock time. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @@ -35,7 +42,10 @@ public sealed interface Schedule { DAYS } - /** A fixed offset from {@code after}: "every 15 minutes", "every 6 hours". No time of day. */ + /** + * A fixed cadence repeating from when the policy was last seen: "every 15 minutes", "every 6 + * hours". Time of day is irrelevant. + */ record Every(long count, Unit unit) implements Schedule { public Every { if (count <= 0) { @@ -81,7 +91,8 @@ public sealed interface Schedule { @Override public ZonedDateTime nextAfter(ZonedDateTime after) { - // Soonest of the next 7 days landing on a chosen weekday, at the configured time. + // The soonest of the next 7 days that lands on a chosen weekday, at the configured + // time. for (int i = 0; i <= 7; i++) { ZonedDateTime candidate = after.plusDays(i).with(at); if (candidate.isAfter(after) && days.contains(candidate.getDayOfWeek())) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java index 477de122e..e18347dc3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/TriggerConfig.java @@ -3,9 +3,17 @@ package stirling.software.proprietary.policy.model; import java.util.Map; /** - * A {@link Policy}'s automatic trigger; {@code type} keys a trigger bean (e.g. "schedule"). Manual - * running is not a trigger kind: a manual-only policy carries a {@code null} {@code TriggerConfig}. - * Answers only "when"; file sources are the policy's {@link InputSpec}s. + * A {@link Policy}'s optional automatic trigger - what fires it without a person asking. {@code + * type} selects a trigger kind ("schedule", and in future "webhook", "folder-watch", ...); {@code + * options} carries type-specific configuration (a {@link Schedule}, a webhook secret, ...). + * + *

Manual running is not a trigger kind: every policy can always be run on demand, so a + * policy with no automatic trigger simply has a {@code null} {@code TriggerConfig}. A trigger + * answers only "when"; where a run's files come from is a separate concern owned by the policy's + * {@link InputSpec sources}. + * + *

Data-driven and parallel to {@link OutputSpec}: new trigger kinds are new {@code type} values + * handled by a new trigger bean, with no change to the model. */ public record TriggerConfig(String type, Map options) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/WaitState.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/WaitState.java index 7b0947413..7fff6c9fd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/WaitState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/WaitState.java @@ -3,10 +3,14 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * Resumable snapshot captured when a run pauses ({@link PolicyRunStatus#WAITING_FOR_INPUT}). {@code - * resumeStepIndex} is the 0-based step to continue from; {@code pendingFileIds} are intermediate - * files held in {@code FileStorage} (not in-memory resources) so a pause survives the worker thread - * ending or a node restart. + * Captured when a run pauses in {@link PolicyRunStatus#WAITING_FOR_INPUT}. Together with the run's + * {@link PipelineDefinition} this is the resumable snapshot: {@code resumeStepIndex} is the 0-based + * step to continue from, and {@code pendingFileIds} are the intermediate files (stored in {@code + * FileStorage}, so they survive the worker thread ending or a node restart) that become the input + * to the resumed run. + * + *

Stored as fileIds rather than in-memory resources by design: a paused run must be resumable + * long after its worker thread has gone. */ public record WaitState(String reason, int resumeStepIndex, List pendingFileIds) { public WaitState { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index e7302bd01..6ecec3c93 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -22,10 +22,13 @@ import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.OutputSpec; /** - * Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are - * streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s - * carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry, - * so folder outputs are not downloadable via {@code /files/{id}}. + * Writes a run's output files to a directory on disk. The destination is the {@code directory} + * option of the {@link OutputSpec}. + * + *

Files are streamed to disk (so large outputs are not buffered) and given unique names to avoid + * clobbering existing files. The returned {@link ResultFile}s describe what was written (path + + * size); they carry a synthetic id because the deliverable is the file on disk, not a {@code + * FileStorage} entry, so folder outputs are not downloadable via {@code /files/{id}}. */ @Slf4j @Service @@ -92,7 +95,11 @@ public class FolderOutputSink implements PolicyOutputSink { return Path.of(directory.toString()); } - // Strip any directory component / "../" so a crafted output name cannot escape targetDir. + /** + * The resource's filename reduced to a bare, traversal-free name: any directory component or + * "../" is stripped so a crafted output name cannot escape {@code targetDir}. Falls back to a + * synthetic name when the filename is absent or reduces to nothing usable. + */ private static String safeName(String filename, int index) { if (filename == null || filename.isBlank()) { return "output-" + index; @@ -104,7 +111,7 @@ public class FolderOutputSink implements PolicyOutputSink { return name; } - // Non-colliding path, appending " (n)" before the extension. + /** Resolve a non-colliding path in {@code dir}, appending " (n)" before the extension. */ private static Path uniqueTarget(Path dir, String filename) { Path candidate = dir.resolve(filename); if (!Files.exists(candidate)) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index cb5848f52..bef5d46b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -17,8 +17,9 @@ import stirling.software.common.service.FileStorage; import stirling.software.proprietary.policy.model.OutputSpec; /** - * Default sink: stores each output in {@code FileStorage} so it is downloadable via {@code GET - * /api/v1/general/files/{fileId}}. Used for manual runs whose results return to the caller. + * Default output sink: stores each output file in {@code FileStorage} so it is downloadable via + * {@code GET /api/v1/general/files/{fileId}}. This is the destination for manually-triggered runs + * whose results are returned to the caller. */ @Service @RequiredArgsConstructor diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java index 6e6c80ba4..c98b55ad6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java @@ -9,9 +9,11 @@ import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.model.OutputSpec; /** - * Delivers a finished run's outputs to a destination, returning {@link ResultFile} descriptors for - * the run record. Implementations are beans selected by {@link #supports(OutputSpec)}, so a new - * destination (folder, S3) is just a new bean. + * Delivers a finished run's output files to a destination, returning durable {@link ResultFile} + * descriptors (fileId + metadata) for the run record. + * + *

Implementations are Spring beans selected by {@link #supports(OutputSpec)}. New destinations + * (folder, S3) are added as new beans without changing the engine. */ public interface PolicyOutputSink { @@ -21,10 +23,19 @@ public interface PolicyOutputSink { /** Whether this sink can handle the given output spec. */ boolean supports(OutputSpec spec); - /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */ + /** + * Check that an output spec is usable, throwing {@link IllegalArgumentException} if not. Called + * when a policy is saved so misconfiguration fails fast rather than at run time. + */ default void validate(OutputSpec spec) {} - /** Persist/deliver the output files and return their descriptors. */ + /** + * Persist/deliver the output files and return their descriptors. + * + * @param runId the run these outputs belong to + * @param outputs the final pipeline output resources + * @param spec the requested destination + */ List deliver(String runId, List outputs, OutputSpec spec) throws IOException; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/progress/PolicyProgressListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/progress/PolicyProgressListener.java index 98c89f3a7..84eae284e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/progress/PolicyProgressListener.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/progress/PolicyProgressListener.java @@ -1,17 +1,22 @@ package stirling.software.proprietary.policy.progress; /** - * Receives live progress as a pipeline run executes (SSE stream, job notes, or both). Step indices - * are 1-based. All methods default to no-ops. + * Receives live progress as a pipeline run executes. Implementations forward to an SSE stream, + * write job notes for polling, or both. Step indices are 1-based. + * + *

All methods default to no-ops so callers implement only what they surface. */ public interface PolicyProgressListener { + /** A listener that ignores all progress. */ PolicyProgressListener NOOP = new PolicyProgressListener() {}; + /** Called immediately before step {@code stepIndex} of {@code stepCount} begins. */ default void onStepStart(int stepIndex, int stepCount, String operation) {} + /** Called immediately after step {@code stepIndex} of {@code stepCount} completes. */ default void onStepComplete(int stepIndex, int stepCount, String operation) {} - /** Keep-alive tick so downstream connections can detect disconnects promptly. */ + /** Called on a keep-alive tick so downstream connections can detect disconnects promptly. */ default void onHeartbeat() {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index d4a1259d3..2ec94d1eb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -9,8 +9,9 @@ import java.util.concurrent.ConcurrentHashMap; import stirling.software.proprietary.policy.model.Policy; /** - * In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore} - * is the runtime bean. + * In-memory {@link PolicyStore}. Not the runtime bean - {@link JpaPolicyStore} is the durable + * store. Kept as a lightweight, dependency-free implementation for tests and for any future no- + * database mode. */ public class InProcessPolicyStore implements PolicyStore { @@ -31,8 +32,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.trigger(), policy.sources(), policy.steps(), - policy.output(), - policy.teamId()); + policy.output()); policies.put(id, stored); return stored; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 731e9f5d5..e4e05ea4c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -13,8 +13,9 @@ import stirling.software.proprietary.policy.model.Policy; import tools.jackson.databind.ObjectMapper; /** - * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via - * {@link PolicyEntity}, with scalar columns kept in sync for querying. + * Durable {@link PolicyStore} backed by JPA. The runtime store whenever the proprietary module runs + * (a datasource is always present). Policies are persisted as JSON via {@link PolicyEntity}; the + * scalar columns are kept in sync for querying. */ @Service @RequiredArgsConstructor @@ -38,8 +39,7 @@ public class JpaPolicyStore implements PolicyStore { policy.trigger(), policy.sources(), policy.steps(), - policy.output(), - policy.teamId()); + policy.output()); PolicyEntity entity = new PolicyEntity(); entity.setId(id); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 944b49461..d5aad6130 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -12,11 +12,13 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives - * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies - * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch - * their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the - * security entities. + * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. + * + *

The whole policy is stored as JSON in {@code policyJson} (authoritative on read, and the same + * serialization the API uses); the scalar columns are denormalized copies for querying - notably + * {@code triggerType} + {@code enabled} so background triggers can fetch their policies. Ownership + * is a plain {@code owner} string rather than a foreign key, to stay decoupled from the security + * entities; richer team scoping can be layered on later. */ @Entity @Table(name = "policies") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index c9a2a0ecf..16b2ae861 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -5,19 +5,24 @@ import java.util.Optional; import stirling.software.proprietary.policy.model.Policy; -/** Stores {@link Policy} definitions. */ +/** + * Stores {@link Policy} definitions. The in-memory implementation backs simple deployments now; a + * durable (JPA) implementation can replace it behind this interface without touching callers. + */ public interface PolicyStore { - /** Create or update; a blank/absent id is assigned. Returns the stored policy. */ + /** Create or update a policy. A blank/absent id is assigned; returns the stored policy. */ Policy save(Policy policy); Optional get(String id); List all(); - /** Enabled policies with the given trigger type, for background triggers. */ + /** + * Enabled policies whose automatic trigger is of the given type (used by background triggers). + */ List findByTriggerType(String triggerType); - /** Returns whether the policy existed. */ + /** Remove a policy; returns whether it existed. */ boolean delete(String id); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index ebd379560..dbff801b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -33,14 +33,20 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.PolicyStore; /** - * Fires policies when a file lands in one of their folder sources, rather than polling on a timer. + * Fires policies the moment a file lands in one of their folder sources, instead of polling on a + * timer. The trigger only reads that location; turning it into files is still the source's job. * - *

The watch is a latency optimisation, not a source of truth: a periodic reconcile sweep ({@code - * watchReconcileSeconds}) re-syncs watched dirs and re-runs every policy, covering files that - * pre-dated the watch, dropped events, and filesystems that emit none (NFS, bind mounts). Redundant - * runs are harmless since {@link InputSource} does the claiming. + *

The watcher is a latency optimisation, not a source of truth, so this pairs an event watch + * with a low-frequency reconcile sweep ({@code watchReconcileSeconds}). The reconcile both + * (a) re-syncs which directories are watched as policies are created/edited/deleted and folders + * appear on disk, and (b) runs every folder-watch policy once, catching files that pre-dated the + * watch, events lost to inotify-queue overflow, and changes on filesystems that do not deliver + * events at all (NFS, many container bind mounts). Both paths just call {@link PolicyRunner#run}; + * the {@link InputSource} does the claiming, so a redundant run finds nothing to claim and is + * harmless. * - *

Watch state is in memory, so this assumes a single node and rebuilds registrations on restart. + *

Like {@link ScheduleTrigger}, watch state is per-node and in memory; this assumes a single + * node and rebuilds its registrations on restart from the {@link PolicyStore}. */ @Slf4j @Service @@ -59,7 +65,7 @@ public class FolderWatchTrigger implements PolicyTrigger { private volatile boolean running; - // Package-visible so tests can drive syncRegistrations() against a real service. + // Package-visible (not private) so tests can drive syncRegistrations() against a real service. volatile WatchService watchService; private volatile ScheduledExecutorService reconciler; @@ -119,7 +125,8 @@ public class FolderWatchTrigger implements PolicyTrigger { } private void watchLoop() { - // Capture once: stop() may null the field; close() still wakes take()/poll() on this local. + // Capture the service once: stop() may null the field, and a local avoids racing that to an + // NPE (close() still wakes take()/poll() on this same instance). WatchService watcher = watchService; if (watcher == null) { return; @@ -139,8 +146,9 @@ public class FolderWatchTrigger implements PolicyTrigger { } /** - * Coalesce a burst of file-system events into one set of affected directories: drain everything - * arriving within the quiet period. Event kinds are irrelevant; any event means "go look". + * Collect the directories touched by {@code first} and any further events that arrive within + * the quiet period, so a burst of file-system events becomes a single set of affected + * directories. The event kinds are irrelevant: any event on a watched dir just means "go look". */ private Set drainBurst(WatchService watcher, WatchKey first) { long quietPeriodMs = applicationProperties.getPolicies().getWatchQuietPeriodMs(); @@ -192,7 +200,7 @@ public class FolderWatchTrigger implements PolicyTrigger { } } - /** Reconcile safety net: run every folder-watch policy regardless of watch events. */ + /** The reconcile safety net: run every folder-watch policy regardless of watch events. */ void runAll() { for (Policy policy : policyStore.findByTriggerType(TYPE)) { try { @@ -206,7 +214,10 @@ public class FolderWatchTrigger implements PolicyTrigger { } } - /** Register newly-wanted dirs that exist on disk, cancel ones no longer wanted. */ + /** + * Bring the set of watched directories in line with the current folder-watch policies: register + * newly required directories that exist on disk, and cancel ones no longer wanted. + */ synchronized void syncRegistrations() { if (watchService == null) { return; @@ -263,8 +274,11 @@ public class FolderWatchTrigger implements PolicyTrigger { return dirs; } - // Absolute + normalised so registration keys and event-time matching compare regardless of how - // the path was configured. + /** + * The normalised, absolute directories this policy's sources expose to watch. Normalisation + * makes registration keys and event-time matching comparable regardless of how the path was + * configured. + */ private List watchDirsOf(Policy policy) { List dirs = new ArrayList<>(); for (InputSpec spec : policy.sources()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index a9cb36371..1e3718d7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -3,21 +3,36 @@ package stirling.software.proprietary.policy.trigger; import stirling.software.proprietary.policy.model.Policy; /** - * Decides when a policy runs. On firing it hands the policy to {@code PolicyRunner}; it - * never resolves sources itself. New trigger kinds are just new beans of this type. + * An automatic trigger: the thing that decides when a policy runs without a person asking. + * A trigger owns a {@link #type()} (matching {@code TriggerConfig.type()}); when its condition + * fires it hands the policy to the {@code PolicyRunner}, which pulls the policy's sources and + * starts the runs. A trigger never resolves sources itself. + * + *

Triggers are background, configuration-driven beans (schedule, and in future webhook or + * folder-watch): on {@link #start()} they begin watching/scheduling for the policies returned by + * {@code PolicyStore.findByTriggerType(type())}, and stop on {@link #stop()}. New trigger kinds are + * new beans of this type; the runner and the {@code Policy} model do not change. + * + *

Manual running is not a trigger - every policy can always be run on demand via the {@code + * PolicyRunner} regardless of whether it has a trigger. */ public interface PolicyTrigger { - /** Matches {@code TriggerConfig.type()}. */ + /** Stable identifier for this trigger kind, matching {@code TriggerConfig.type()}. */ String type(); /** - * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole - * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that. + * Check that this trigger is usable for the given policy, throwing {@link + * IllegalArgumentException} if not. Called when a policy is saved so misconfiguration fails + * fast rather than at fire time. Receives the whole {@link Policy} (not just its {@code + * TriggerConfig}) so a trigger whose firing depends on the policy's sources (folder-watch) can + * assert that relationship; most triggers only inspect {@code policy.trigger()}. */ default void validate(Policy policy) {} + /** Begin activating policies of this type (e.g. start the schedule sweep). */ default void start() {} + /** Stop activating and release any resources. */ default void stop() {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java index 7d71138df..1e87dcd4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java @@ -8,7 +8,14 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** Starts and stops every {@link PolicyTrigger} with the application lifecycle. */ +/** + * Starts and stops every {@link PolicyTrigger} with the application lifecycle. Background triggers + * (schedule, and future folder/S3) begin watching on {@link #start()} and release resources on + * {@link #stop()}; request-driven triggers (manual) are no-ops. + * + *

This is the single activation point for triggers - a new background trigger only has to be a + * {@link PolicyTrigger} bean. + */ @Slf4j @Service @RequiredArgsConstructor diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 65391e978..f138710d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -24,9 +24,16 @@ import stirling.software.proprietary.policy.store.PolicyStore; import tools.jackson.databind.ObjectMapper; /** - * Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy. + * Fires policies on a {@link Schedule}. On {@link #start()} it sweeps on a fixed interval; each + * sweep finds the enabled "schedule" policies and runs any whose next firing has come due since it + * last fired. * - *

Last-fire times are in memory, so this assumes a single node and resets on restart. + *

The trigger only decides when: once a policy is due it hands it to the {@link + * PolicyRunner}, which pulls from the policy's configured sources and starts the runs. The trigger + * knows nothing about folders, buckets, or how many runs a sweep produces. + * + *

Caveat: last-fire times are tracked in memory, so this assumes a single node and resets + * on restart; cluster-wide coordination (leader election) is a follow-up. */ @Slf4j @Service @@ -94,7 +101,8 @@ public class ScheduleTrigger implements PolicyTrigger { continue; } - // Baseline a newly-seen policy to now so it does not fire immediately. + // First time we see a policy, baseline its last-fire to now so it does not fire + // immediately; subsequent sweeps fire it once its next firing has passed. Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now); ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone())); if (!next.toInstant().isAfter(now)) { @@ -106,8 +114,9 @@ public class ScheduleTrigger implements PolicyTrigger { } /** - * Validated schedule-trigger options: the {@link Schedule} and the zone it runs in (UTC by - * default). + * The typed, validated form of a schedule trigger's options: the {@link Schedule} and the zone + * its wall-clock kinds are evaluated in (default UTC). Construction fails for a missing/invalid + * schedule or zone. */ record ScheduleConfig(Schedule schedule, ZoneId zone) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 94dc41a23..e0dabced4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -1,13 +1,11 @@ package stirling.software.proprietary.security; import java.sql.SQLException; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import jakarta.annotation.PostConstruct; @@ -39,17 +37,6 @@ public class InitialSecuritySetup { private final ApplicationProperties applicationProperties; private final DatabaseServiceInterface databaseService; private final UserLicenseSettingsService licenseSettingsService; - private final Environment environment; - - /** - * SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that - * scan/rewrite the whole user table (default-team backfill, seat-license grandfathering) don't - * apply - and against a large SaaS user table they stall startup with full-table loads + - * per-row saveAll. Per-user team assignment happens in SupabaseAuthenticationFilter instead. - */ - private boolean isSaas() { - return Arrays.asList(environment.getActiveProfiles()).contains("saas"); - } @PostConstruct public void init() { @@ -64,15 +51,9 @@ public class InitialSecuritySetup { } configureJWTSettings(); + assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); - if (isSaas()) { - log.info( - "SaaS profile active - skipping self-host user-table bootstrap" - + " (default-team backfill, seat-license grandfathering)."); - } else { - assignUsersToDefaultTeamIfMissing(); - initializeUserLicenseSettings(); - } + initializeUserLicenseSettings(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.error("Failed to initialize security setup.", e); System.exit(1); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 509de7e81..295e90029 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -978,35 +978,23 @@ public class UserController { } } - // Lists enabled users for the signing user picker, scoped by storage.signing.userListScope: - // 'org' (default) = whole instance, anything else = caller's team only (fail-closed). + /** + * List all enabled users for selection in signing workflows. + * + * @param principal The authenticated user + * @return List of user summaries + */ @GetMapping("/users") public ResponseEntity> listUsers(Principal principal) { if (principal == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - // Fail-closed: only literal "org" opens the whole instance; anything else scopes to team. - String scope = applicationProperties.getStorage().getSigning().getUserListScope(); - boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim()); - - List source; - if (teamScoped) { - Optional callerOpt = userService.findByUsernameIgnoreCase(principal.getName()); - if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) { - // No team: return only the caller rather than leak the org. - source = callerOpt.map(List::of).orElse(List.of()); - } else { - // KNOWN LIMITATION: scopes the team via the single User.team FK - correct while - // acceptInvitation() collapses users to one team; revisit if multi-team enabled. - source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId()); - } - } else { - source = userRepository.findAll(); - } - List users = - source.stream().filter(User::isEnabled).map(this::toUserSummaryDTO).toList(); + userRepository.findAll().stream() + .filter(User::isEnabled) + .map(this::toUserSummaryDTO) + .toList(); return ResponseEntity.ok(users); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 2b2d22cfc..e39b1a330 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -87,11 +87,7 @@ public class User implements UserDetails, Serializable { private String email; // SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments. - // Column is `supabase_auth_id` (canonical name from the initial Supabase remote - // schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a - // parallel `supabase_id` column that was used by Java; V17 backfilled and dropped - // it. Field name is kept as `supabaseId` to avoid a wide refactor of callers. - @Column(name = "supabase_auth_id", unique = true) + @Column(name = "supabase_id", unique = true) private UUID supabaseId; @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 4e6c51531..0178e041a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -18,7 +18,6 @@ import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.client.RestClientResponseException; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; @@ -386,13 +385,6 @@ public class AiWorkflowService { return new WorkflowState.Terminal( cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); } catch (Exception e) { - AiWorkflowResponse limit = paygLimitResponseOrNull(e); - if (limit != null) { - log.info( - "AI markdown conversion blocked by downstream entitlement gate ({})", - limit.getErrorCode()); - return new WorkflowState.Terminal(limit); - } log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); @@ -478,14 +470,6 @@ public class AiWorkflowService { log.error("Tool {} timed out: {}", endpointPath, e.getMessage()); return new WorkflowState.Terminal(cannotContinue(toolTimeoutMessage(endpointPath, e))); } catch (Exception e) { - AiWorkflowResponse limit = paygLimitResponseOrNull(e); - if (limit != null) { - log.info( - "AI workflow tool {} blocked by downstream entitlement gate ({})", - endpointPath, - limit.getErrorCode()); - return new WorkflowState.Terminal(limit); - } log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e); return new WorkflowState.Terminal(cannotContinue(toolFailureMessage(endpointPath, e))); } @@ -601,13 +585,6 @@ public class AiWorkflowService { log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason); return new WorkflowState.Terminal(cannotContinue(reason)); } catch (Exception e) { - AiWorkflowResponse limit = paygLimitResponseOrNull(e); - if (limit != null) { - log.info( - "AI workflow plan blocked by downstream entitlement gate ({})", - limit.getErrorCode()); - return new WorkflowState.Terminal(limit); - } log.error("Failed to execute plan: {}", e.getMessage(), e); return new WorkflowState.Terminal( cannotContinue("Plan execution failed: " + e.getMessage())); @@ -745,33 +722,6 @@ public class AiWorkflowService { return response; } - /** - * If {@code e} is a downstream usage-limit block — a 401/402 from a tool call carrying the saas - * EntitlementGuard's {@code error} sentinel — build a terminal response that carries the - * structured code (+ {@code subscribed}) through to the client, so it can pop the matching - * usage-limit modal instead of surfacing the raw "tool failed: 402…" text. Returns null for any - * other failure, so the caller falls back to its normal tool-failure handling. - * - *

The agent's tool calls run server-side (loopback HTTP via {@link PolicyExecutor}), so this - * 402 never reaches the frontend's API-client interceptor that pops the modal for direct calls - * — same gap the policy auto-run path bridges in {@code PolicyEngine}. - */ - private AiWorkflowResponse paygLimitResponseOrNull(Throwable e) { - if (!(e instanceof RestClientResponseException rce)) { - return null; - } - String code = DownstreamEntitlementError.extractCode(rce); - if (code == null) { - return null; - } - AiWorkflowResponse response = new AiWorkflowResponse(); - response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE); - response.setReason("You've reached your current usage limit."); - response.setErrorCode(code); - response.setErrorSubscribed(DownstreamEntitlementError.extractSubscribed(rce)); - return response; - } - /** * Drive the engine's streaming orchestrator endpoint. Progress events are forwarded to {@code * listener} as they arrive (each one keeps the SSE connection to the frontend alive too). The diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java deleted file mode 100644 index 52d9500a5..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/DownstreamEntitlementError.java +++ /dev/null @@ -1,64 +0,0 @@ -package stirling.software.proprietary.service; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.springframework.web.client.RestClientResponseException; - -/** - * Reads the {@code error} sentinel and {@code subscribed} flag out of a downstream 401/402 JSON - * body — e.g. the saas EntitlementGuard's {@code - * {"error":"PAYG_LIMIT_REACHED","subscribed":false}}. - * - *

Server-side run paths (policy auto-run, AI agent workflows) execute tool calls via loopback - * HTTP, so a usage-limit 402 surfaces as a {@link RestClientResponseException} rather than reaching - * the frontend's API-client interceptor. These helpers let those paths pass the structured code - * through to the client, which maps it to the right usage-limit modal instead of showing a generic - * failure. - * - *

Regex (not a JSON parse) on purpose: the body is a small, server-controlled shape and this - * keeps the proprietary module free of any billing-layer (saas) coupling. - */ -public final class DownstreamEntitlementError { - - private DownstreamEntitlementError() {} - - /** Matches the {@code "error":"CODE"} field of a small JSON error body. */ - private static final Pattern ERROR_CODE_FIELD = - Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); - - /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */ - private static final Pattern SUBSCRIBED_FIELD = - Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)"); - - /** - * Pull the {@code error} sentinel out of a downstream 401/402 JSON body. Returns null for other - * statuses or an unmatched body, in which case the caller treats it as a generic failure. - */ - public static String extractCode(RestClientResponseException e) { - int status = e.getStatusCode().value(); - if (status != 401 && status != 402) { - return null; - } - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - Matcher m = ERROR_CODE_FIELD.matcher(body); - return m.find() ? m.group(1) : null; - } - - /** - * Pull the {@code subscribed} flag out of the body (present on the saas {@code - * PAYG_LIMIT_REACHED}/{@code FEATURE_DEGRADED} responses). Null when absent — the client then - * defaults to the free-limit modal. - */ - public static Boolean extractSubscribed(RestClientResponseException e) { - String body = e.getResponseBodyAsString(); - if (body == null || body.isBlank()) { - return null; - } - Matcher m = SUBSCRIBED_FIELD.matcher(body); - return m.find() ? Boolean.valueOf(m.group(1)) : null; - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java index 5bee580b7..06f23e1ff 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java @@ -53,30 +53,6 @@ class McpAudienceValidatorTest { assertThat(result.hasErrors()).isTrue(); } - @Test - void acceptedAudience_isAccepted_alongsideResourceId() { - // Supabase-style IdP: every token carries aud=authenticated, never the resource id. - McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated")); - assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors()) - .isFalse(); - assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse(); - assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors()) - .isTrue(); - } - - @Test - void blankAcceptedAudienceEntries_areIgnored() { - McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " ")); - assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue(); - assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse(); - } - - @Test - void blankResourceIdWithOnlyBlankAccepted_failsClosed() { - McpAudienceValidator blank = new McpAudienceValidator("", List.of(" ")); - assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue(); - } - private static Jwt tokenWithAudience(List audience) { return new Jwt( "header.payload.signature", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java index 1e814ea58..53f0b2660 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java @@ -116,8 +116,7 @@ class McpOAuthIntegrationTest { assertThat(response.statusCode()).isEqualTo(401); String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse(""); assertThat(wwwAuth).contains("resource_metadata="); - // The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource. - assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp"); + assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource"); } @Test @@ -248,24 +247,6 @@ class McpOAuthIntegrationTest { assertThat(response.body()).contains("mcp.tools.read"); } - @Test - void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception { - // RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain - // with authorization_servers populated; a default/uncustomized document here makes MCP - // clients fall back to treating this server as its own authorization server. - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp")) - .GET() - .build(); - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); - assertThat(response.statusCode()).isEqualTo(200); - assertThat(response.body()).contains(RESOURCE_ID); - assertThat(response.body()).contains("authorization_servers"); - assertThat(response.body()).contains(ISSUER); - assertThat(response.body()).contains("mcp.tools.read"); - } - private HttpResponse postMcp(String token, String body) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java deleted file mode 100644 index 17d15d1f1..000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpScopeMetadataDisabledTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package stirling.software.proprietary.mcp.security; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; - -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.mcp.McpServerController; -import stirling.software.proprietary.mcp.tools.DescribeOperationTool; -import stirling.software.proprietary.security.service.UserService; - -/** - * Regression test for the protected-resource metadata when {@code mcp.scopes-enabled=false} (the - * SaaS/Supabase setup, where the IdP cannot mint {@code mcp.tools.*} scopes). Advertising scopes - * the authorization server can't issue makes spec-compliant MCP clients request them and get - * bounced with {@code invalid_request}, so the metadata must omit them when scopes are not - * enforced. - */ -@SpringBootTest( - classes = McpScopeMetadataDisabledTest.TestApp.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class McpScopeMetadataDisabledTest { - - private static final String ISSUER = "https://test-issuer.example.com"; - private static final String RESOURCE_ID = "http://localhost/mcp"; - - @LocalServerPort private int port; - - private final HttpClient http = HttpClient.newHttpClient(); - - // McpSecurityConfig is @ConditionalOnProperty("mcp.enabled"); that condition reads the Spring - // Environment, so it must be set here (the ApplicationProperties bean alone is not enough to - // register the chain). - @DynamicPropertySource - static void mcpProperties(DynamicPropertyRegistry registry) { - registry.add("mcp.enabled", () -> "true"); - } - - @Test - void metadata_omitsToolScopes_whenScopesDisabled() throws Exception { - String body = getMetadata("/.well-known/oauth-protected-resource"); - assertThat(body).contains(RESOURCE_ID); - assertThat(body).contains(ISSUER); - assertThat(body).doesNotContain("mcp.tools.read"); - assertThat(body).doesNotContain("mcp.tools.write"); - } - - @Test - void pathInsertedMetadata_omitsToolScopes_whenScopesDisabled() throws Exception { - String body = getMetadata("/.well-known/oauth-protected-resource/mcp"); - assertThat(body).contains(RESOURCE_ID); - assertThat(body).contains("authorization_servers"); - assertThat(body).doesNotContain("mcp.tools.read"); - assertThat(body).doesNotContain("mcp.tools.write"); - } - - private String getMetadata(String path) throws Exception { - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + port + path)) - .GET() - .build(); - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); - assertThat(response.statusCode()).isEqualTo(200); - return response.body(); - } - - @SpringBootConfiguration - @EnableAutoConfiguration - @Import({McpSecurityConfig.class, McpServerController.class, DescribeOperationTool.class}) - static class TestApp { - - @Bean - ApplicationProperties applicationProperties() { - ApplicationProperties props = new ApplicationProperties(); - props.getMcp().setEnabled(true); - props.getMcp().setScopesEnabled(false); - props.getMcp().getAuth().setIssuerUri(ISSUER); - // No real JWKS fetch happens for the permitAll metadata endpoint; a placeholder URI is - // fine because NimbusJwtDecoder.withJwkSetUri(...) resolves the key set lazily. - props.getMcp().getAuth().setJwksUri(ISSUER + "/jwks"); - props.getMcp().getAuth().setResourceId(RESOURCE_ID); - props.getAutomaticallyGenerated().setAppVersion("test"); - return props; - } - - @Bean - UserService userService() { - return org.mockito.Mockito.mock(UserService.class); - } - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java deleted file mode 100644 index 811aae6b4..000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package stirling.software.proprietary.policy.config; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import java.util.Optional; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; - -/** Self-hosted policy context: a global admin may edit; scoping uses the current user's team. */ -@ExtendWith(MockitoExtension.class) -class AdminPolicyManagementAuthorityTest { - - @Mock private UserService userService; - - private AdminPolicyManagementAuthority authority() { - return new AdminPolicyManagementAuthority(userService); - } - - @Test - void adminMayEditPolicies() { - when(userService.isCurrentUserAdmin()).thenReturn(true); - assertTrue(authority().canEditPolicies()); - } - - @Test - void nonAdminMayNot() { - when(userService.isCurrentUserAdmin()).thenReturn(false); - assertFalse(authority().canEditPolicies()); - } - - @Test - void currentUserTeamIdResolvesFromTheCurrentUsersTeam() { - Team team = new Team(); - team.setId(42L); - User user = new User(); - user.setTeam(team); - when(userService.getCurrentUsername()).thenReturn("alice"); - when(userService.findByUsername("alice")).thenReturn(Optional.of(user)); - assertEquals(42L, authority().currentUserTeamId()); - } - - @Test - void currentUserTeamIdIsNullWhenNoCurrentUser() { - when(userService.getCurrentUsername()).thenReturn(null); - assertNull(authority().currentUserTeamId()); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java deleted file mode 100644 index 9f86b87ae..000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package stirling.software.proprietary.policy.config; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import java.util.List; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.service.UserServiceInterface; -import stirling.software.proprietary.policy.model.OutputSpec; -import stirling.software.proprietary.policy.model.Policy; - -/** - * {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only - * their own team's policies (admins included — there is no cross-team escape). Login disabled - * (single-user) bypasses scoping. - */ -@ExtendWith(MockitoExtension.class) -class PolicyAccessGuardTest { - - @Mock private UserServiceInterface userService; - @Mock private PolicyManagementAuthority policyManagementAuthority; - - private PolicyAccessGuard guard(boolean loginEnabled) { - ApplicationProperties properties = new ApplicationProperties(); - properties.getSecurity().setEnableLogin(loginEnabled); - return new PolicyAccessGuard(userService, properties, policyManagementAuthority); - } - - @Test - void visibleFiltersToTheCallersTeam() { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); - List all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null)); - List visible = guard(true).visible(all); - assertEquals(2, visible.size()); - assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId()))); - } - - @Test - void visibleReturnsEverythingWhenLoginDisabled() { - List all = List.of(inTeam(1L), inTeam(2L)); - assertEquals(all, guard(false).visible(all)); - } - - @Test - void canAccessOnlyOwnTeamsPolicy() { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); - assertTrue(guard(true).canAccess(inTeam(1L))); - assertFalse(guard(true).canAccess(inTeam(2L))); - assertFalse(guard(true).canAccess(inTeam(null))); - } - - @Test - void canAccessAnythingWhenLoginDisabled() { - assertTrue(guard(false).canAccess(inTeam(2L))); - } - - @Test - void ownerAndTeamForNewPolicyComeFromTheCurrentUserWhenLoginEnabled() { - when(userService.getCurrentUsername()).thenReturn("alice"); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(7L); - assertEquals("alice", guard(true).ownerForNewPolicy()); - assertEquals(7L, guard(true).teamForNewPolicy()); - } - - @Test - void ownerAndTeamForNewPolicyAreNullWhenLoginDisabled() { - assertNull(guard(false).ownerForNewPolicy()); - assertNull(guard(false).teamForNewPolicy()); - } - - private static Policy inTeam(Long teamId) { - return new Policy( - "p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index ddb4eced3..e47916dbe 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -28,13 +28,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.MDC; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.client.HttpClientErrorException; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.FileStorage; @@ -174,35 +170,6 @@ class PolicyEngineTest { verify(taskManager, never()).setComplete(runId); } - @Test - void runBlockedByUsageLimit_surfacesErrorCodeAndSubscribed() throws Exception { - // A downstream tool call gets a 402 entitlement block. The run fails, but its errorCode + - // subscribed are taken from the 402 body so the client can pop the right usage-limit modal - // (the policy 402 happens server-side, out of reach of the apiClient interceptor). - when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); - String body = "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":true}"; - when(internalApiClient.post(eq(ROTATE), any())) - .thenThrow( - HttpClientErrorException.create( - HttpStatus.PAYMENT_REQUIRED, - "Payment Required", - HttpHeaders.EMPTY, - body.getBytes(java.nio.charset.StandardCharsets.UTF_8), - java.nio.charset.StandardCharsets.UTF_8)); - - PolicyRun run = - engine.submit( - definition(new PipelineStep(ROTATE, Map.of())), - PolicyInputs.of(List.of(pdf("input", "input.pdf"))), - PolicyProgressListener.NOOP) - .completion() - .get(10, TimeUnit.SECONDS); - - assertEquals(PolicyRunStatus.FAILED, run.getStatus()); - assertEquals("PAYG_LIMIT_REACHED", run.getErrorCode()); - assertEquals(Boolean.TRUE, run.getErrorSubscribed()); - } - @Test void runPolicyExecutesThePolicysPipeline() throws Exception { when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); @@ -237,86 +204,6 @@ class PolicyEngineTest { verify(internalApiClient).post(eq(ROTATE), any()); } - @Test - void runPolicyDispatchesToolCallsAsTheOwner() throws Exception { - // Billing-attribution regression: the pipeline runs on a background worker thread, but the - // policy owner must be propagated as the audit principal so InternalApiClient (and thus - // PAYG) attributes each tool call to the owner — not the INTERNAL_API_USER fallback. - when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); - when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); - int[] counter = {0}; - when(fileStorage.storeInputStream(any(InputStream.class), anyString())) - .thenAnswer( - inv -> - new StoredFile( - "file-" + ++counter[0], - ((InputStream) inv.getArgument(0)).readAllBytes().length)); - - String[] principalAtDispatch = {""}; - when(internalApiClient.post(eq(ROTATE), any())) - .thenAnswer( - inv -> { - principalAtDispatch[0] = MDC.get("auditPrincipal"); - return ResponseEntity.ok(pdf("rotated", "rotated.pdf")); - }); - - Policy policy = - new Policy( - "p1", - "rotate", - "alice", - true, - null, - List.of(new PipelineStep(ROTATE, Map.of())), - OutputSpec.inline()); - - engine.runPolicy( - policy, - PolicyInputs.of(List.of(pdf("input", "input.pdf"))), - PolicyProgressListener.NOOP) - .completion() - .get(10, TimeUnit.SECONDS); - - assertEquals("alice", principalAtDispatch[0]); - } - - @Test - void adHocRunDispatchesToolCallsAsTheSubmittingUser() throws Exception { - // Ad-hoc runs (no stored policy) bill whoever kicked them off; the principal is captured on - // the request thread (here simulated via MDC) and re-established on the worker thread. - when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); - when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); - int[] counter = {0}; - when(fileStorage.storeInputStream(any(InputStream.class), anyString())) - .thenAnswer( - inv -> - new StoredFile( - "file-" + ++counter[0], - ((InputStream) inv.getArgument(0)).readAllBytes().length)); - - String[] principalAtDispatch = {""}; - when(internalApiClient.post(eq(ROTATE), any())) - .thenAnswer( - inv -> { - principalAtDispatch[0] = MDC.get("auditPrincipal"); - return ResponseEntity.ok(pdf("rotated", "rotated.pdf")); - }); - - MDC.put("auditPrincipal", "bob"); // the request thread's audit principal - try { - engine.submit( - definition(new PipelineStep(ROTATE, Map.of())), - PolicyInputs.of(List.of(pdf("input", "input.pdf"))), - PolicyProgressListener.NOOP) - .completion() - .get(10, TimeUnit.SECONDS); - } finally { - MDC.remove("auditPrincipal"); - } - - assertEquals("bob", principalAtDispatch[0]); - } - @Test void runIsQueuedUnderResourcePressure() { when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java index adf948609..12620b6e8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java @@ -16,7 +16,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.env.Environment; import org.springframework.test.util.ReflectionTestUtils; import stirling.software.common.model.ApplicationProperties; @@ -37,7 +36,6 @@ class InitialSecuritySetupTest { @Mock private TeamService teamService; @Mock private DatabaseServiceInterface databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; - @Mock private Environment environment; private ApplicationProperties applicationProperties; private InitialSecuritySetup initialSecuritySetup; @@ -55,15 +53,13 @@ class InitialSecuritySetupTest { when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) .thenReturn(Optional.of(internalUser)); when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam); - when(environment.getActiveProfiles()).thenReturn(new String[] {}); initialSecuritySetup = new InitialSecuritySetup( userService, teamService, applicationProperties, databaseService, - licenseSettingsService, - environment); + licenseSettingsService); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 61e4bf17c..f5ea59f10 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -1,17 +1,13 @@ package stirling.software.proprietary.security.controller.api; -import static org.junit.jupiter.api.Assertions.assertEquals; 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.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -154,186 +150,4 @@ class UserControllerTest { verify(loginAttemptService).resetAttempts("lockeduser"); } - - // --------------------------------------------------------------------- - // GET /api/v1/user/users - storage.signing.userListScope scoping - // --------------------------------------------------------------------- - - private static User user(long id, String username, boolean enabled, Team team) { - User u = new User(); - u.setId(id); - u.setUsername(username); - u.setEnabled(enabled); - u.setTeam(team); - return u; - } - - private static Team team(long id, String name) { - Team t = new Team(); - t.setId(id); - t.setName(name); - return t; - } - - private static Authentication auth(String username) { - return new UsernamePasswordAuthenticationToken(username, "pw"); - } - - @Test - void listUsersDefaultScopeIsOrgWide() throws Exception { - // Default "org" scope returns every enabled user via findAll(), no team lookup. - Team alpha = team(1L, "alpha"); - when(userRepository.findAll()) - .thenReturn( - List.of( - user(1L, "a@alpha.com", true, alpha), - user(2L, "b@alpha.com", true, alpha))); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("a@alpha.com"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].username").value("a@alpha.com")) - .andExpect(jsonPath("$[1].username").value("b@alpha.com")); - - verify(userRepository, never()).findAllByTeamId(any()); - verify(userService, never()).findByUsernameIgnoreCase(anyString()); - } - - @Test - void listUsersOrgScopeFiltersDisabledUsers() throws Exception { - Team alpha = team(1L, "alpha"); - when(userRepository.findAll()) - .thenReturn( - List.of( - user(1L, "enabled@alpha.com", true, alpha), - user(2L, "disabled@alpha.com", false, alpha))); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("enabled@alpha.com"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].username").value("enabled@alpha.com")); - } - - @Test - void listUsersTeamScopeReturnsOnlyCallerTeam() throws Exception { - applicationProperties.getStorage().getSigning().setUserListScope("team"); - Team alpha = team(7L, "alpha"); - User caller = user(1L, "caller@alpha.com", true, alpha); - when(userService.findByUsernameIgnoreCase("caller@alpha.com")) - .thenReturn(Optional.of(caller)); - when(userRepository.findAllByTeamId(7L)) - .thenReturn(List.of(caller, user(2L, "mate@alpha.com", true, alpha))); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("caller@alpha.com"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].teamName").value("alpha")); - - verify(userRepository).findAllByTeamId(7L); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersTeamScopeWithMissingCallerReturnsEmpty() throws Exception { - applicationProperties.getStorage().getSigning().setUserListScope("team"); - when(userService.findByUsernameIgnoreCase("ghost@alpha.com")).thenReturn(Optional.empty()); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("ghost@alpha.com"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); - - verify(userRepository, never()).findAllByTeamId(any()); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersTeamScopeWithNullTeamReturnsSelfOnly() throws Exception { - applicationProperties.getStorage().getSigning().setUserListScope("team"); - User caller = user(1L, "solo@nowhere.com", true, null); - when(userService.findByUsernameIgnoreCase("solo@nowhere.com")) - .thenReturn(Optional.of(caller)); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("solo@nowhere.com"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].username").value("solo@nowhere.com")); - - verify(userRepository, never()).findAllByTeamId(any()); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersFailsClosedOnUnrecognisedScope() throws Exception { - // Any non-"org" value must restrict to the caller's team, not leak the instance. - applicationProperties.getStorage().getSigning().setUserListScope("tewm"); - Team alpha = team(3L, "alpha"); - when(userService.findByUsernameIgnoreCase("caller@alpha.com")) - .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); - when(userRepository.findAllByTeamId(3L)) - .thenReturn(List.of(user(1L, "caller@alpha.com", true, alpha))); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("caller@alpha.com"))) - .andExpect(status().isOk()); - - verify(userRepository).findAllByTeamId(3L); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersFailsClosedOnBlankScope() throws Exception { - applicationProperties.getStorage().getSigning().setUserListScope(" "); - Team alpha = team(4L, "alpha"); - when(userService.findByUsernameIgnoreCase("caller@alpha.com")) - .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); - when(userRepository.findAllByTeamId(4L)).thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("caller@alpha.com"))) - .andExpect(status().isOk()); - - verify(userRepository).findAllByTeamId(4L); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersFailsClosedOnNullScope() throws Exception { - // A null value must also fail closed to the caller's team. - applicationProperties.getStorage().getSigning().setUserListScope(null); - Team alpha = team(9L, "alpha"); - when(userService.findByUsernameIgnoreCase("caller@alpha.com")) - .thenReturn(Optional.of(user(1L, "caller@alpha.com", true, alpha))); - when(userRepository.findAllByTeamId(9L)).thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("caller@alpha.com"))) - .andExpect(status().isOk()); - - verify(userRepository).findAllByTeamId(9L); - verify(userRepository, never()).findAll(); - } - - @Test - void listUsersOrgScopeIsCaseInsensitive() throws Exception { - applicationProperties.getStorage().getSigning().setUserListScope("ORG"); - when(userRepository.findAll()).thenReturn(List.of(user(1L, "a@alpha.com", true, null))); - - mockMvc.perform(get("/api/v1/user/users").principal(auth("a@alpha.com"))) - .andExpect(status().isOk()); - - verify(userRepository).findAll(); - verify(userRepository, never()).findAllByTeamId(any()); - } - - @Test - void listUsersRequiresAuthentication() throws Exception { - mockMvc.perform(get("/api/v1/user/users")).andExpect(status().isUnauthorized()); - - verify(userRepository, never()).findAll(); - verify(userRepository, never()).findAllByTeamId(any()); - } - - @Test - void signingUserListScopeDefaultsToOrg() { - // Self-host backward-compat: default must stay "org" (saas profile flips it to "team"). - assertEquals( - "org", new ApplicationProperties().getStorage().getSigning().getUserListScope()); - } } 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..848d596d8 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 @@ -38,19 +38,11 @@ import lombok.RequiredArgsConstructor; 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.saas.ai.model.AiCreateSession; import stirling.software.saas.ai.repository.AiCreateSessionRepository; import stirling.software.saas.ai.service.AiCreateProxyService; import stirling.software.saas.ai.service.AiCreateSessionService; -import stirling.software.saas.payg.cap.RequiresFeature; -import stirling.software.saas.payg.charge.ChargeContext; -import stirling.software.saas.payg.charge.JobChargeService; -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; @@ -62,7 +54,6 @@ import stirling.software.saas.util.CreditHeaderUtils; @Tag(name = "AI") @Hidden @RequiredArgsConstructor -@RequiresFeature(FeatureGate.AI_SUPPORT) @Slf4j public class AiCreateController { @@ -73,7 +64,6 @@ public class AiCreateController { private final TeamCreditService teamCreditService; private final UserRepository userRepository; private final CreditHeaderUtils creditHeaderUtils; - private final JobChargeService jobChargeService; @PostMapping("/sessions") public ResponseEntity createSession( @@ -94,45 +84,9 @@ public class AiCreateController { session.getUserId(), session.getDocType(), session.getTemplateId()); - chargeForCreate(session); return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId())); } - /** - * Bill one document for a new AI Create session — creating a document is the charge point; - * follow-up edits on the same session (outline / reprompt / draft / template / stream) carry no - * charge. AI usage is billable, so a JWT (web) session counts the same as an API-key one. - * - *

Best-effort: a charge failure must not block the user's session. Entitlement is already - * enforced upstream — this controller is {@code @RequiresFeature(AI_SUPPORT)}, so the - * EntitlementGuard 402s a team with no AI allowance before we ever get here; this call only - * does the accounting (free-grant draw + Stripe meter). - */ - private void chargeForCreate(AiCreateSession session) { - try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - User user = AuthenticationUtils.getCurrentUser(auth, userRepository); - if (user == null || user.getTeam() == null) { - return; - } - JobSource source = - auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB; - ChargeContext ctx = - new ChargeContext( - user.getId(), - user.getTeam().getId(), - source, - ProcessType.SINGLE_TOOL, - BillingCategory.AI); - jobChargeService.chargeStandalone(ctx, 1); - } catch (RuntimeException e) { - log.warn( - "AI create session {} charge failed; session proceeds unbilled: {}", - session.getSessionId(), - e.getMessage()); - } - } - @DeleteMapping("/sessions/{sessionId}") public ResponseEntity deleteSession(@PathVariable String sessionId) { sessionService.deleteSessionForCurrentUser(sessionId); diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc461..bf2e4894e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -26,8 +26,6 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.saas.ai.model.AiCreateSession; import stirling.software.saas.ai.model.AiCreateSessionStatus; import stirling.software.saas.ai.service.AiCreateSessionService; -import stirling.software.saas.payg.cap.RequiresFeature; -import stirling.software.saas.payg.model.FeatureGate; @RestController @Profile("saas") @@ -35,7 +33,6 @@ import stirling.software.saas.payg.model.FeatureGate; @Tag(name = "AI") @Hidden @RequiredArgsConstructor -@RequiresFeature(FeatureGate.AI_SUPPORT) @Slf4j public class AiCreateInternalController { 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..8b6ae714a 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 @@ -28,8 +28,6 @@ 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; @@ -38,7 +36,6 @@ import stirling.software.saas.util.CreditHeaderUtils; @RestController @Profile("saas") @RequestMapping("/api/v1/ai") -@RequiresFeature(FeatureGate.AI_SUPPORT) @Tag(name = "AI") @Hidden @Slf4j 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 index 2cce36a5a..978e9b34f 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java @@ -9,10 +9,8 @@ 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") +@Profile("saas") @RequiredArgsConstructor public class CreditInterceptorConfig implements WebMvcConfigurer { 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 index 6f33554fc..2c1be26f4 100644 --- a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java +++ b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java @@ -25,9 +25,8 @@ 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") +@Profile("saas") @RequestMapping("/api/v1/credits") @Tag(name = "Credit Management", description = "Endpoints for managing user API credits") @RequiredArgsConstructor 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 index 24c73ac78..280f8be6d 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java @@ -39,10 +39,8 @@ 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") +@Profile("saas") @Slf4j @Order(1) public class CreditErrorAdvice { 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 index e76e06e3c..51e82b4db 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java @@ -27,10 +27,8 @@ 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") +@Profile("saas") @Slf4j public class CreditSuccessAdvice implements ResponseBodyAdvice { 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 index f50c1df0f..631217e1c 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java @@ -32,10 +32,8 @@ 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") +@Profile("saas") @Slf4j public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java b/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java deleted file mode 100644 index 7ecad6a8b..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java +++ /dev/null @@ -1,56 +0,0 @@ -package stirling.software.saas.payg.api; - -/** - * Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on - * {@code wallet_policy.cap_units}. - * - *

The cap is application-layer only — Stripe stays on a flat-priced single meter, so the - * conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader - * who set "$25" should read back "$25" on the next page load. - * - *

V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the - * same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against - * the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money - * conversion lands. - * - *

Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar - * inputs (the cap-edit field is an integer text box) so the floor on the read path is the only - * place rounding ever shows up, and only when an admin set a non-multiple via SQL. - */ -public final class CapMoneyUnits { - - /** - * Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the - * unit-count meter the engine writes to the ledger; not tied to Stripe pricing. - */ - public static final int UNITS_PER_USD = 100; - - /** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */ - public static final int CENTS_PER_USD = 100; - - private CapMoneyUnits() {} - - /** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */ - public static long usdToUnits(int capUsd) { - if (capUsd < 0) { - throw new IllegalArgumentException("capUsd must be >= 0"); - } - return (long) capUsd * UNITS_PER_USD; - } - - /** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */ - public static int unitsToUsd(long capUnits) { - if (capUnits < 0L) { - return 0; - } - return (int) (capUnits / UNITS_PER_USD); - } - - /** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */ - public static long usdToCents(int capUsd) { - if (capUsd < 0) { - throw new IllegalArgumentException("capUsd must be >= 0"); - } - return (long) capUsd * CENTS_PER_USD; - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java deleted file mode 100644 index 041eaa8f4..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ /dev/null @@ -1,424 +0,0 @@ -package stirling.software.saas.payg.api; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import io.swagger.v3.oas.annotations.Hidden; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow; -import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; -import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; -import stirling.software.saas.payg.billing.TeamBillingContext; -import stirling.software.saas.payg.billing.TeamBillingService; -import stirling.software.saas.payg.entitlement.EntitlementService; -import stirling.software.saas.payg.entitlement.EntitlementSnapshot; -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.model.LedgerEntryType; -import stirling.software.saas.payg.policy.PaygTeamExtensions; -import stirling.software.saas.payg.repository.PaygShadowChargeRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; -import stirling.software.saas.payg.repository.WalletLedgerRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.wallet.WalletLedgerEntry; -import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; -import stirling.software.saas.util.AuthenticationUtils; - -/** - * Read + cap-mutation surface backing the FE PAYG Plan page. - * - *

{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns - * a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for - * spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link - * WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster - * of team members + their per-member usage; member callers see an empty roster. - * - *

{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the - * cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot - * cache so the next read reflects the change immediately. Only leaders may call this; the team is - * derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize} - * — the team id never appears on the path or query string. - * - *

Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in - * V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as - * {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription - * id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose - * subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}). - */ -@Slf4j -@Hidden -@RestController -@RequestMapping("/api/v1/payg") -@Profile("saas") -public class PaygWalletController { - - static final String STATUS_FREE = "free"; - static final String STATUS_SUBSCRIBED = "subscribed"; - static final String ROLE_LEADER = "leader"; - static final String ROLE_MEMBER = "member"; - - /** - * Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a - * membership — shouldn't happen post-migration). Teams always get the live {@code - * pricing_policy.free_tier_units} grant via {@link TeamBillingService}. - */ - private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500; - - private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE; - - private final EntitlementService entitlementService; - private final TeamBillingService billingService; - private final TeamMembershipRepository memberRepo; - private final PaygTeamExtensionsRepository extRepo; - private final WalletPolicyRepository policyRepo; - private final WalletLedgerRepository ledgerRepo; - private final PaygShadowChargeRepository shadowRepo; - private final UserRepository userRepository; - - public PaygWalletController( - EntitlementService entitlementService, - TeamBillingService billingService, - TeamMembershipRepository memberRepo, - PaygTeamExtensionsRepository extRepo, - WalletPolicyRepository policyRepo, - WalletLedgerRepository ledgerRepo, - PaygShadowChargeRepository shadowRepo, - UserRepository userRepository) { - this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService"); - this.billingService = Objects.requireNonNull(billingService, "billingService"); - this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); - this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); - this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo"); - this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo"); - this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo"); - this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); - } - - // --------------------------------------------------------------------------------------- - // GET /wallet — the single FE fetch - // --------------------------------------------------------------------------------------- - - @GetMapping("/wallet") - @PreAuthorize("isAuthenticated()") - @Transactional(readOnly = true) - public ResponseEntity getWallet(Authentication auth) { - User user; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (SecurityException e) { - // SecurityException maps to 401 per the existing controller convention. - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - Optional primary = primaryMembership(user.getId()); - if (primary.isEmpty()) { - // Authenticated user without a team — shouldn't happen post-migration, but we don't - // want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI - // rather than blowing up on a null body. - return ResponseEntity.ok(emptySnapshot()); - } - - TeamMembership membership = primary.get(); - Long teamId = membership.getTeam().getId(); - boolean isLeader = membership.getRole() == TeamRole.LEADER; - - // Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement - // snapshot (period spend over that window) share the same composition service, so what - // the customer sees here is exactly what the 402 guard enforces. - TeamBillingContext billing = billingService.forTeam(teamId); - EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); - - String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE; - - boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null; - Integer capMajor = - billing.capMoneyMinor() != null - ? Math.toIntExact(billing.capMoneyMinor() / 100) - : null; - - // Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is - // lifetime free used, cap is the grant size; subscribed → spend is this month's net - // billable - // docs, cap is the monthly paid-doc ceiling (null = uncapped). - int spend = clampToInt(snap.periodSpendUnits()); - Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null; - - CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd()); - - // Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was - // already netted out at charge time, so this is the metered total, not spend − grant. - long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd()); - Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null); - - List members = - isLeader - ? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd()) - : List.of(); - - WalletSnapshotResponse body = - new WalletSnapshotResponse( - teamId, - status, - isLeader ? ROLE_LEADER : ROLE_MEMBER, - ISO_DATE.format(snap.periodStart().toLocalDate()), - ISO_DATE.format(snap.periodEnd().toLocalDate()), - spend, - limit, - clampToInt(billing.freeGrantUnits()), - clampToInt(billing.freeRemainingUnits()), - billing.perDocMinor(), - billing.currency(), - estimatedBill, - capMajor, - noCap, - billing.subscriptionId(), - spend, - breakdown, - members, - buildActivity(teamId)); - return ResponseEntity.ok(body); - } - - private CategoryBreakdown buildBreakdown( - Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { - Map byCategory = new HashMap<>(); - for (Object[] row : - ledgerRepo.sumPeriodAmountByCategory( - teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) { - if (row.length >= 2 - && row[0] instanceof BillingCategory cat - && row[1] instanceof Number n) { - byCategory.put(cat, n.longValue()); - } - } - return new CategoryBreakdown( - clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)), - clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)), - clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L))); - } - - /** - * Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as - * credits-back; system entries without a category render as {@code other}. - */ - private List buildActivity(Long teamId) { - List out = new ArrayList<>(); - for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) { - BillingCategory category = e.getBillingCategory(); - String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other"; - String categoryLabel = category != null ? categoryDisplayName(category) : "Document"; - String label = - e.getEntryType() == LedgerEntryType.REFUND - ? "Refund — " + categoryLabel - : categoryLabel + " usage"; - int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits()); - out.add( - new ActivityRow( - e.getId(), - kind, - label, - e.getOccurredAt() != null ? e.getOccurredAt().toString() : "", - docUnits)); - } - return out; - } - - private static String categoryDisplayName(BillingCategory category) { - return switch (category) { - case API -> "API"; - case AI -> "AI"; - case AUTOMATION -> "Automation"; - case BYPASSED -> "Manual"; - }; - } - - // --------------------------------------------------------------------------------------- - // PATCH /cap — leader-only, cap is application-layer, no Stripe call - // --------------------------------------------------------------------------------------- - - @PatchMapping("/cap") - @PreAuthorize("isAuthenticated()") - @Transactional - public ResponseEntity updateCap( - @Valid @RequestBody UpdateCapRequest req, Authentication auth) { - User user; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (SecurityException e) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - Optional primary = primaryMembership(user.getId()); - if (primary.isEmpty()) { - // No team → can't have a wallet to cap. - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - TeamMembership membership = primary.get(); - if (membership.getRole() != TeamRole.LEADER) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - Long teamId = membership.getTeam().getId(); - - WalletPolicy policy = - policyRepo - .findByTeamId(teamId) - .orElseGet( - () -> { - WalletPolicy created = new WalletPolicy(); - created.setTeamId(teamId); - return created; - }); - - if (req.noCap()) { - policy.setCapUnits(null); - policy.setCapSourceMoney(null); - } else { - long capMinor = CapMoneyUnits.usdToCents(req.capUsd()); - policy.setCapSourceMoney(capMinor); - // Derived document allowance: store both the money intent and the unit translation. - // The live snapshot recomputes from cap_source_money + current rate; this stored value - // is the enforcement fallback when the rate is unreachable. - TeamBillingContext billing = billingService.forTeam(teamId); - Optional docCap = billingService.docCapForMoney(billing, capMinor); - if (docCap.isPresent()) { - policy.setCapUnits(docCap.get()); - } else { - // Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy - // money-as-units conversion so the cap still binds rather than silently lifting. - log.warn( - "Per-document rate unavailable for team {}; storing legacy cap_units" - + " conversion.", - teamId); - policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd())); - } - } - policyRepo.save(policy); - entitlementService.invalidate(teamId); - return ResponseEntity.noContent().build(); - } - - /** Request body for {@link #updateCap}. */ - public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} - - // --------------------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------------------- - - private Optional primaryMembership(Long userId) { - List rows = memberRepo.findPrimaryMembership(userId); - return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); - } - - private List buildMemberRows( - Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { - List all = memberRepo.findByTeamId(teamId); - if (all.isEmpty()) { - return List.of(); - } - LocalDateTime[] window = {periodStart, periodEnd}; - List out = new ArrayList<>(all.size()); - for (TeamMembership tm : all) { - User u = tm.getUser(); - if (u == null) { - continue; - } - // We could batch these; team sizes are small (FE design assumes ≤ ~20 members per - // team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to - // a single GROUP BY actor_user_id query. - long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate. - try { - spend = -memberSpend(teamId, u.getId(), window[0], window[1]); - } catch (RuntimeException e) { - log.warn( - "buildMemberRows: per-member spend lookup failed for user {}", - u.getId(), - e); - } - String displayName = - Optional.ofNullable(u.getUsername()) - .orElse(Optional.ofNullable(u.getEmail()).orElse("")); - out.add( - new MemberRow( - Long.toString(u.getId()), - displayName, - Optional.ofNullable(u.getEmail()).orElse(""), - clampToInt(spend))); - } - return out; - } - - /** - * Per-member period spend in signed ledger units (debits are negative). Helper so the test - * slice can override without standing up a real database, and so the controller doesn't inline - * the negation arithmetic at every call site. - */ - long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) { - return ledgerRepo.sumPeriodAmountForMember( - teamId, userId, LedgerEntryType.DEBIT, start, end); - } - - private static LocalDateTime[] currentMonthWindow() { - java.time.YearMonth ym = java.time.YearMonth.now(); - LocalDateTime start = ym.atDay(1).atStartOfDay(); - LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(); - return new LocalDateTime[] {start, end}; - } - - private static int clampToInt(long v) { - if (v <= 0) return 0; - if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE; - return (int) v; - } - - private WalletSnapshotResponse emptySnapshot() { - LocalDateTime[] window = currentMonthWindow(); - return new WalletSnapshotResponse( - null, // teamId — unknown when the caller has no team membership - STATUS_FREE, - ROLE_MEMBER, - ISO_DATE.format(window[0].toLocalDate()), - ISO_DATE.format(window[1].toLocalDate()), - 0, - FREE_TIER_LIMIT_UNITS_FALLBACK, - FREE_TIER_LIMIT_UNITS_FALLBACK, - FREE_TIER_LIMIT_UNITS_FALLBACK, - null, - null, - null, - null, - false, - null, - 0, - new CategoryBreakdown(0, 0, 0), - List.of(), - Collections.emptyList()); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java deleted file mode 100644 index 0b29347c8..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ /dev/null @@ -1,100 +0,0 @@ -package stirling.software.saas.payg.api; - -import java.math.BigDecimal; -import java.util.List; - -/** - * JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the - * frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members}, - * breakdowns, recent activity) used by the PAYG Plan page. - * - *

Every number is real: the billing window is the Stripe subscription's current period (via Sync - * Engine) for subscribed teams, the one-time free grant size comes from {@code - * pricing_policy.free_tier_units} (live balance from {@code - * payg_team_extensions.free_units_remaining}), and the per-document rate comes from the - * subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders - * "unknown" — never a substituted default. - * - * @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the - * Supabase edge functions that create Stripe Checkout / portal sessions — those run outside - * Spring Security and have no other way to resolve the caller's team. - * @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once - * a card is on file and the engine bills meter events. - * @param role the current caller's role within their team — {@code "leader"} or {@code "member"}. - * Controls which UI variant the frontend renders. - * @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe - * subscription period when subscribed, the calendar month otherwise. - * @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle. - * @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free - * team this is the lifetime free documents used so far ({@code freeAllowance − freeRemaining}); - * for a subscribed team it's this month's net billable documents. - * @param billableLimit the team's document ceiling for the matching window: the one-time free grant - * ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for - * capped subscribed teams; {@code null} when subscribed with no cap (uncapped). - * @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free"). - * Never resets; survives subscribing. Applies to billable categories only. - * @param freeRemaining one-time free documents still available to the team ({@code - * payg_team_extensions.free_units_remaining}). 0 = grant exhausted. - * @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be - * fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved. - * @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null} - * when unknown (free teams, unresolved rate). - * @param estimatedBillMinor estimated charges so far this period in minor units of {@code - * currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free - * portion was already netted out at charge time. Informational — the Stripe invoice is - * authoritative. {@code null} when the rate is unknown. - * @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free - * or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs - * it with {@code currency} for the symbol.) - * @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when - * subscribed. - * @param stripeSubscriptionId Stripe subscription id from {@code - * payg_team_extensions.payg_subscription_id}; {@code null} when status is free. - * @param spendUnitsThisPeriod documents debited this cycle across billable categories. - * @param categoryBreakdown per-category spend slice over the same billing window. - * @param members leader-only roster of team members + their per-member sub-caps. Empty for member - * callers. - * @param recent latest wallet-ledger entries (newest first) for the activity feed. - */ -public record WalletSnapshotResponse( - Long teamId, - String status, - String role, - String billingPeriodStart, - String billingPeriodEnd, - int billableUsed, - Integer billableLimit, - int freeAllowance, - int freeRemaining, - BigDecimal pricePerDocMinor, - String currency, - Long estimatedBillMinor, - Integer capUsd, - boolean noCap, - String stripeSubscriptionId, - int spendUnitsThisPeriod, - CategoryBreakdown categoryBreakdown, - List members, - List recent) { - - /** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */ - public record CategoryBreakdown(int api, int ai, int automation) {} - - /** - * One row of the team-members table on the leader's Plan page — display-only per-member usage. - * (Per-member sub-caps aren't enforced yet. When they ship, a cap field returns here.) - */ - public record MemberRow(String userId, String name, String email, int spendUnits) {} - - /** - * One wallet-ledger entry shaped for the FE activity feed. - * - * @param id ledger entry id (stable React key) - * @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or - * {@code other} for system entries - * @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"} - * @param ts ISO-8601 local timestamp of the entry - * @param docUnits absolute document count of the entry - */ - public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {} -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java deleted file mode 100644 index 55988fbe8..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java +++ /dev/null @@ -1,47 +0,0 @@ -package stirling.software.saas.payg.billing; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live - * here and must not be conflated: - * - *

    - *
  • the one-time lifetime free grant ({@link #freeGrantUnits} total, {@link - * #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split - * of every job; never resets, survives subscribing; - *
  • the monthly billing window ({@link #periodStart}/{@link #periodEnd}) and the - * optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice - * + cap only. - *
- * - * @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set. - * Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false. - * @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free - * @param periodStart inclusive start of the monthly billing window — the Stripe subscription's - * current period when subscribed, calendar month otherwise - * @param periodEnd exclusive end of the monthly billing window - * @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the - * denominator for "used X of N free". Never resets. - * @param freeRemainingUnits one-time free documents still available ({@code - * payg_team_extensions.free_units_remaining}). 0 = grant exhausted. - * @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the - * rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute - * @param currency lower-case ISO 4217 of the subscription's Price; null when unknown - * @param capMoneyMinor leader-set monthly spending cap in minor units ({@code - * wallet_policy.cap_source_money}); null = no cap configured - * @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney / - * perDocRate)}; null = uncapped, or the team is not subscribed - */ -public record TeamBillingContext( - boolean subscribed, - String subscriptionId, - LocalDateTime periodStart, - LocalDateTime periodEnd, - long freeGrantUnits, - long freeRemainingUnits, - BigDecimal perDocMinor, - String currency, - Long capMoneyMinor, - Long monthlyCapDocUnits) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java deleted file mode 100644 index 260efbd9a..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java +++ /dev/null @@ -1,272 +0,0 @@ -package stirling.software.saas.payg.billing; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Objects; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.saas.payg.policy.PaygTeamExtensions; -import stirling.software.saas.payg.policy.PricingPolicy; -import stirling.software.saas.payg.policy.PricingPolicyService; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.stripe.StripeSubscriptionDao; -import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate; -import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling; -import stirling.software.saas.payg.wallet.WalletPolicy; - -/** - * Single composition point for "what does billing look like for this team right now." Both the - * entitlement hot path and the wallet endpoint read from here, so what the customer sees is what - * the guard enforces. - * - *

Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant): - * - *

    - *
  • Free grant — one-time, per team. Size from {@code pricing_policy.free_tier_units}; - * live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained - * by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and - * drives the free-vs-paid split. - *
  • Monthly window + cap — the Stripe subscription period (calendar month otherwise) and - * the optional money cap. Govern the subscribed invoice + spending cap only. The per-document - * rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit). - *
- * - *

Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate} - * cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks. - * Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the - * authoritative decrement happens in {@code JobChargeService} against the row directly; this cache - * is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor. - */ -@Slf4j -@Service -@Profile("saas") -public class TeamBillingService { - - static final int CACHE_TTL_SECONDS = 30; - private static final int CACHE_MAX_SIZE = 10_000; - - /** - * In-app display/estimate currency. The app prices in dollars; Stripe handles real currency - * selection at checkout. Used to pick the right Price for un-subscribed teams. - */ - private static final String DISPLAY_CURRENCY = "usd"; - - /** - * Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve - * an un-subscribed team's rate from (the default policy carries no price ids in the seed). - */ - private static final String PAYG_LOOKUP_KEY = "plan:processor"; - - private final PaygTeamExtensionsRepository extensionsRepository; - private final WalletPolicyRepository walletPolicyRepository; - private final PricingPolicyService pricingPolicyService; - private final StripeSubscriptionDao subscriptionDao; - - private final Cache cache; - - public TeamBillingService( - PaygTeamExtensionsRepository extensionsRepository, - WalletPolicyRepository walletPolicyRepository, - PricingPolicyService pricingPolicyService, - StripeSubscriptionDao subscriptionDao) { - this.extensionsRepository = - Objects.requireNonNull(extensionsRepository, "extensionsRepository"); - this.walletPolicyRepository = - Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository"); - this.pricingPolicyService = - Objects.requireNonNull(pricingPolicyService, "pricingPolicyService"); - this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao"); - this.cache = - Caffeine.newBuilder() - .maximumSize(CACHE_MAX_SIZE) - .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS)) - .build(); - } - - public TeamBillingContext forTeam(Long teamId) { - Objects.requireNonNull(teamId, "teamId"); - return cache.get(teamId, this::compute); - } - - /** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */ - public void invalidate(Long teamId) { - if (teamId != null) { - cache.invalidate(teamId); - } - } - - private TeamBillingContext compute(Long teamId) { - Optional extOpt = extensionsRepository.findById(teamId); - Optional walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId); - - String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null); - // payg_subscription_id is the single subscription switch. payg_link_subscription sets it - // (alongside stripe_customer_id, in the same write) on customer.subscription.created; - // payg_unlink_subscription nulls it on customer.subscription.deleted while deliberately - // keeping stripe_customer_id so a future re-subscribe can reuse the Stripe customer. So a - // cancelled team has a null subscription id and must read as free again. - // - // We deliberately do NOT fall back to stripe_customer_id presence. payg_link_subscription - // is the only writer of that column and it writes it together with the subscription id, so - // it can never be set "before the webhook lands" — there is no gap for it to bridge. A - // customer-id fallback would instead keep every team that ever subscribed pinned to - // subscribed forever (the customer outlives the subscription), which is the cancelled-team - // bug this guards against. - boolean subscribed = subscriptionId != null; - - long freeGrant = resolveGrant(teamId); - long freeRemaining = - extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining) - .map(Long::longValue) - .orElse(0L); - - Optional billing = - subscriptionId != null - ? subscriptionDao.findBilling(subscriptionId) - : Optional.empty(); - - LocalDateTime[] window = - billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()}) - .orElseGet(TeamBillingService::calendarMonthWindow); - - BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null); - String currency = billing.map(SubscriptionBilling::currency).orElse(null); - - // Un-subscribed teams have no Stripe subscription to read a rate from, but the cap - // estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from - // the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and - // the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on - // `subscribed`, so this never starts enforcing a cap on a free team. - if (!subscribed && perDocMinor == null) { - Optional rate = - subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY); - if (rate.isPresent()) { - perDocMinor = rate.get().perDocMinor(); - currency = rate.get().currency(); - } - } - - Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null); - Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null); - - Long monthlyCapDocUnits = - resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor); - - return new TeamBillingContext( - subscribed, - subscriptionId, - window[0], - window[1], - freeGrant, - freeRemaining, - perDocMinor, - currency, - capMoneyMinor, - monthlyCapDocUnits); - } - - /** The policy grant size — the "N" denominator for display; the counter is the live balance. */ - private long resolveGrant(Long teamId) { - try { - PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); - Long grant = policy.getFreeTierUnits(); - return grant == null ? 0L : grant; - } catch (RuntimeException e) { - log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage()); - return 0L; - } - } - - /** - * The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The - * one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge - * time. The cap purely limits how many paid documents the team will fund per billing period. - * - *

    - *
  • not subscribed → null (the free grant, not a money cap, is what bounds them); - *
  • subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units}; - *
  • subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)}; - *
  • subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN). - *
- */ - private Long resolveMonthlyCap( - boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) { - if (!subscribed) { - return null; - } - if (capMoneyMinor == null) { - return legacyCapUnits; // admin-set unit cap (source money null) still applies - } - if (perDocMinor != null && perDocMinor.signum() > 0) { - return BigDecimal.valueOf(capMoneyMinor) - .divide(perDocMinor, 0, RoundingMode.FLOOR) - .longValue(); - } - log.warn( - "Per-document rate unavailable; enforcing stored cap_units fallback ({}).", - legacyCapUnits); - return legacyCapUnits; - } - - /** - * Estimated charges for the current period in minor units of {@link - * TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document - * rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown. - * - * @param paidUnitsThisPeriod metered documents this period ({@code payg_units − - * free_units_consumed} summed over the period's charged jobs) - */ - public Optional estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) { - if (ctx.perDocMinor() == null) { - return Optional.empty(); - } - long paid = Math.max(0, paidUnitsThisPeriod); - BigDecimal bill = - ctx.perDocMinor() - .multiply(BigDecimal.valueOf(paid)) - .setScale(0, RoundingMode.HALF_UP); - return Optional.of(bill.longValue()); - } - - /** - * Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by - * the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT - * added — it's a separate one-time pool. Empty when the rate is unknown. - */ - public Optional docCapForMoney(TeamBillingContext ctx, long capMinor) { - if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) { - return Optional.empty(); - } - return Optional.of( - BigDecimal.valueOf(capMinor) - .divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR) - .longValue()); - } - - /** - * Inclusive-start / exclusive-end window for the calendar month — the monthly billing window - * used when there's no Stripe subscription period to anchor on. - */ - static LocalDateTime[] calendarMonthWindow() { - return calendarMonthWindow(LocalDateTime.now()); - } - - /** Test seam — accepts a clock value so tests don't race the calendar boundary. */ - static LocalDateTime[] calendarMonthWindow(LocalDateTime now) { - java.time.YearMonth ym = java.time.YearMonth.from(now); - return new LocalDateTime[] { - ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay() - }; - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java deleted file mode 100644 index c6a31f389..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java +++ /dev/null @@ -1,42 +0,0 @@ -package stirling.software.saas.payg.cap; - -import org.springframework.web.servlet.HandlerMapping; - -import jakarta.servlet.http.HttpServletRequest; - -/** - * Central definition of the AI document-tool route namespace ({@code /api/v1/ai/tools/**}). - * - *

These tools (e.g. {@code PdfCommentAgentController}, {@code MathAuditorAgentController}) live - * in the {@code proprietary} module, which does not depend on {@code saas} and therefore cannot - * carry the saas-only {@link RequiresFeature} annotation. Rather than weaken the layering, the PAYG - * hot-path components recognise the path prefix instead: - * - *

    - *
  • {@code PaygChargeInterceptor} brings these routes into scope and bills them as {@code - * BillingCategory.AI} on a direct call (an orchestrator-dispatched call still resolves to - * AUTOMATION first, via the {@code X-Stirling-Automation} header); - *
  • {@code EntitlementGuard} gates them on {@link - * stirling.software.saas.payg.model.FeatureGate#AI_SUPPORT}. - *
- * - *

Kept as a single source of truth so the interceptor and the guard can never drift on what - * counts as an AI tool. - */ -public final class AiToolRoutes { - - /** Trailing slash so it matches the tool sub-paths, not a bare {@code /api/v1/ai/tools}. */ - public static final String PREFIX = "/api/v1/ai/tools/"; - - private AiToolRoutes() {} - - /** - * True when the request resolved to an AI document-tool endpoint. Prefers the matched route - * pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI. - */ - public static boolean matches(HttpServletRequest request) { - Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); - String path = pattern instanceof String s ? s : request.getRequestURI(); - return path != null && path.startsWith(PREFIX); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java deleted file mode 100644 index d47b6e736..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java +++ /dev/null @@ -1,101 +0,0 @@ -package stirling.software.saas.payg.cap; - -import java.util.List; - -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; - -/** - * Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade - * thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in - * effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller - * (entitlement service) supplies the inputs. - * - *

State transitions: - * - *

    - *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. - *
  • {@code spend / cap < warnPct} → {@code FULL}. - *
  • MINIMAL semantics: under DEGRADED+MINIMAL manual server-side tools (gated by {@link - * FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link - * FeatureGate#AUTOMATION} and {@link FeatureGate#AI_SUPPORT} are blocked. - *
  • {@code warnPct ≤ spend / cap < degradePct} → {@code WARNED}; feature set still {@link - * FeatureSet#FULL} — the warn band is a notification trigger, not a degradation. - *
  • {@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's - * configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}). - *
- * - *

The percentage compare is integer math. We multiply spend by 100 before dividing — this keeps - * the precision and avoids floating-point on the hot path. Spend × 100 can overflow long at 9.2e16 - * units, which is not a realistic value (would represent quintillions of charged documents); we - * don't guard against it. - */ -public final class CapEvaluator { - - private CapEvaluator() {} - - /** - * Snapshot of one cap evaluation. The caller persists this into the appropriate {@code - * wallet_entitlement_snapshot} row (team-wide or per-member). - */ - public record Evaluation( - EntitlementState state, FeatureSet featureSet, List enabledGates) {} - - public static Evaluation evaluate( - long spendUnits, - Long capUnits, - int warnAtPct, - int degradeAtPct, - FeatureSet degradedFeatureSet) { - - if (capUnits == null || capUnits <= 0) { - return full(); - } - if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) { - // Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise - // degradation. The admin endpoints that set the policy should validate; this - // protects the hot path from a bad row sneaking through. - return full(); - } - - // pct = floor((spend * 100) / cap). Integer arithmetic on the hot path. - long pct = (spendUnits * 100L) / capUnits; - - if (pct >= degradeAtPct) { - FeatureSet effective = - degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL; - return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective)); - } - if (pct >= warnAtPct) { - // Warn band: still FULL feature set, but state flag is set so the FE can show a - // banner / send a notification. The wallet service emits a - // WalletEntitlementChanged event when state transitions; subscribers (email - // reminder, SSE to FE) act on that. - return new Evaluation( - EntitlementState.WARNED, FeatureSet.FULL, gatesFor(FeatureSet.FULL)); - } - return full(); - } - - /** Default enabled gates for a given feature set. */ - public static List gatesFor(FeatureSet set) { - if (set == null) { - return List.of(); - } - return switch (set) { - case FULL -> - List.of( - FeatureGate.OFFSITE_PROCESSING, - FeatureGate.AUTOMATION, - FeatureGate.AI_SUPPORT, - FeatureGate.CLIENT_SIDE); - case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); - case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE); - }; - } - - private static Evaluation full() { - return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL)); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java deleted file mode 100644 index 97a61d0f3..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java +++ /dev/null @@ -1,46 +0,0 @@ -package stirling.software.saas.payg.cap; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import stirling.software.saas.payg.model.FeatureGate; - -/** - * Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by - * {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the - * request is rejected with HTTP 402. - * - *

The annotation is not required on every endpoint. The guard's default rule is: - * - *

    - *
  • {@code @RequiresFeature} present → use exactly those gates. - *
  • No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link - * FeatureGate#OFFSITE_PROCESSING}. - *
  • Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't - * degrade). - *
- * - *

So the only endpoints that need this annotation explicitly are those whose gate is - * different from the default {@code OFFSITE_PROCESSING} — chiefly {@code - * PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link - * FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal. - * - *

Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate; - * the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs - * both {@code AUTOMATION} and {@code AI_SUPPORT}). - * - *

{@code
- * @RequiresFeature(FeatureGate.AUTOMATION)
- * @AutoJobPostMapping("/pipeline")
- * public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
- * }
- */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface RequiresFeature { - - /** One or more gates that must all be enabled for the request to proceed. */ - FeatureGate[] value(); -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java index f5914f853..ff4c94a13 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java @@ -1,6 +1,5 @@ package stirling.software.saas.payg.charge; -import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ProcessType; @@ -9,18 +8,9 @@ import stirling.software.saas.payg.model.ProcessType; * kind of process this is. Does NOT carry policy fields — the charge service resolves the effective * policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from * the live policy. - * - *

{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by - * the interceptor before this context is built. Manual UI tools never reach {@code openProcess} - * (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore - * carries one of {@code API}, {@code AI}, or {@code AUTOMATION}. */ public record ChargeContext( - Long ownerUserId, - Long ownerTeamId, - JobSource source, - ProcessType processType, - BillingCategory billingCategory) { + Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) { public ChargeContext { if (ownerUserId == null) { @@ -32,8 +22,5 @@ public record ChargeContext( if (processType == null) { throw new IllegalArgumentException("processType is required"); } - if (billingCategory == null) { - throw new IllegalArgumentException("billingCategory is required"); - } } } 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..e5832bfd6 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 @@ -11,8 +11,6 @@ import java.util.UUID; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; @@ -23,23 +21,14 @@ import stirling.software.saas.payg.job.JobContext; import stirling.software.saas.payg.job.JobService; import stirling.software.saas.payg.job.JoinOrOpenResult; import stirling.software.saas.payg.job.ProcessingJob; -import stirling.software.saas.payg.meter.PaygMeterReportingService; -import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.JobStatus; -import stirling.software.saas.payg.model.LedgerBucket; -import stirling.software.saas.payg.model.LedgerEntryType; -import stirling.software.saas.payg.model.ReferenceType; import stirling.software.saas.payg.model.ShadowChargeStatus; -import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; import stirling.software.saas.payg.repository.PaygShadowChargeRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.ProcessingJobRepository; -import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.shadow.PaygShadowCharge; -import stirling.software.saas.payg.wallet.WalletLedgerEntry; /** * Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve @@ -66,29 +55,18 @@ public class JobChargeService { private final DocumentClassifier classifier; private final PaygShadowChargeRepository shadowRepository; private final ProcessingJobRepository jobRepository; - private final PaygTeamExtensionsRepository teamExtensionsRepository; - private final PaygMeterReportingService meterReportingService; - private final WalletLedgerRepository ledgerRepository; public JobChargeService( JobService jobService, PricingPolicyService policyService, DocumentClassifier classifier, PaygShadowChargeRepository shadowRepository, - ProcessingJobRepository jobRepository, - PaygTeamExtensionsRepository teamExtensionsRepository, - PaygMeterReportingService meterReportingService, - WalletLedgerRepository ledgerRepository) { + ProcessingJobRepository jobRepository) { this.jobService = Objects.requireNonNull(jobService, "jobService"); this.policyService = Objects.requireNonNull(policyService, "policyService"); this.classifier = Objects.requireNonNull(classifier, "classifier"); this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository"); this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository"); - this.teamExtensionsRepository = - Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository"); - this.meterReportingService = - Objects.requireNonNull(meterReportingService, "meterReportingService"); - this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); } /** @@ -126,115 +104,11 @@ public class JobChargeService { int units = computeUnits(inputs, policy); result.job().setDocUnits(units); - int freeUsed = consumeFreeGrant(ctx, units); - recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed); - recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units); + recordShadowRow(ctx, result.job().getId(), policy.getId(), units); return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED); } - /** - * Charge a fixed number of units for a billable action that isn't file/lineage-driven — e.g. an - * AI Create session, billed once per document at session creation. Opens a standalone - * bookkeeping job (no lineage inputs, so follow-up calls never lineage-join it), draws the - * free-grant split, and writes the shadow + ledger rows exactly as {@link #openProcess} does, - * then closes the job so the paid portion meters to Stripe via the same {@code afterCommit} - * path and idempotency key ({@code process::close}). - * - *

Each call is independent: there is no join/dedup, so two sessions charge twice (correct — - * each is a distinct document). The caller passes the unit count; the policy {@code - * minChargeUnits} floor still applies. Must not be called for {@link BillingCategory#BYPASSED}. - * - * @return the bookkeeping job id (mostly useful for tests / tracing) - */ - @Transactional - public UUID chargeStandalone(ChargeContext ctx, int units) { - Objects.requireNonNull(ctx, "ctx"); - if (ctx.billingCategory() == BillingCategory.BYPASSED) { - throw new IllegalArgumentException("chargeStandalone must not be called for BYPASSED"); - } - - PricingPolicy policy = policyService.getEffectivePolicy(ctx.ownerTeamId()); - int chargeUnits = Math.max(units, policy.getMinChargeUnits()); - int stepLimit = resolveStepLimit(policy, ctx.source()); - - JobContext jobCtx = - new JobContext( - ctx.ownerUserId(), - ctx.ownerTeamId(), - ctx.source(), - ctx.processType(), - policy.getId(), - stepLimit); - ProcessingJob job = jobService.open(jobCtx, chargeUnits); - - int freeUsed = consumeFreeGrant(ctx, chargeUnits); - recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed); - recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits); - - // Close immediately — nothing will lineage-join a standalone job — so the paid portion - // meters via the same afterCommit hook + idempotency key as a normal process completion. - close(job.getId()); - return job.getId(); - } - - /** - * Draw this job's free portion from the team's one-time lifetime grant, atomically, and return - * the units taken (0..{@code units}); the remainder is the paid portion that will be metered to - * Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so - * concurrent same-team charges split the grant exactly — no two jobs can both claim the last - * free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses - * the boundary takes whatever's left (its remaining units bill). Skipped for non-billable / - * team-less calls (BYPASSED never reaches openProcess; guarded defensively). - */ - private int consumeFreeGrant(ChargeContext ctx, int units) { - BillingCategory category = ctx.billingCategory(); - if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) { - return 0; - } - Optional extOpt = - teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId()); - if (extOpt.isEmpty()) { - return 0; - } - PaygTeamExtensions ext = extOpt.get(); - long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining(); - int freeUsed = (int) Math.min(units, Math.max(0L, remaining)); - if (freeUsed > 0) { - ext.setFreeUnitsRemaining(remaining - freeUsed); - teamExtensionsRepository.save(ext); - } - return freeUsed; - } - - /** - * The live spend record. Everything the customer-facing side reads — the wallet endpoint's - * {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary} - * view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow - * rows are the comparison audit trail; this row is what actually counts. - * - *

Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum). - * Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed. - */ - private void recordLedgerDebit( - ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { - BillingCategory category = ctx.billingCategory(); - if (category == null || category == BillingCategory.BYPASSED) { - return; - } - WalletLedgerEntry entry = new WalletLedgerEntry(); - entry.setTeamId(ctx.ownerTeamId()); - entry.setActorUserId(ctx.ownerUserId()); - entry.setEntryType(LedgerEntryType.DEBIT); - entry.setBucket(LedgerBucket.CYCLE); - entry.setAmountUnits(-units); - entry.setReferenceType(ReferenceType.JOB); - entry.setReferenceId(jobId.toString()); - entry.setPolicyId(policyId); - entry.setBillingCategory(category); - ledgerRepository.save(entry); - } - private int resolveStepLimit(PricingPolicy policy, JobSource source) { Integer fromPolicy = policy.getStepLimits() == null ? null : policy.getStepLimits().get(source); @@ -268,28 +142,17 @@ public class JobChargeService { } private void recordShadowRow( - ChargeContext ctx, - java.util.UUID jobId, - Long policyId, - int units, - int freeUnitsConsumed) { + ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { PaygShadowCharge row = new PaygShadowCharge(); row.setTeamId(ctx.ownerTeamId()); row.setJobId(jobId); row.setPolicyId(policyId); row.setPaygUnits(units); - // 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. row.setLegacyCreditsCharged(0); row.setDiffPct(0); row.setStatus(ShadowChargeStatus.CHARGED); - // PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing - // after processing_job is pruned. Never affects what Stripe meters (single flat meter). - row.setBillingCategory(ctx.billingCategory()); - row.setJobSource(ctx.source()); shadowRepository.save(row); } @@ -319,31 +182,6 @@ public class JobChargeService { row.setRefundedAt(now); row.setRefundReason(trimReason(refundReason)); shadowRepository.save(row); - // Compensate the live ledger DEBIT written at openProcess so the period spend - // nets to zero for the failed work. Positive amount mirrors the negative debit; - // same JOB reference ties the pair together. The idempotency guard above (only - // on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation. - BillingCategory category = row.getBillingCategory(); - if (category != null && category != BillingCategory.BYPASSED) { - WalletLedgerEntry refund = new WalletLedgerEntry(); - refund.setTeamId(row.getTeamId()); - refund.setEntryType(LedgerEntryType.REFUND); - refund.setBucket(LedgerBucket.CYCLE); - refund.setAmountUnits(row.getPaygUnits()); - refund.setReferenceType(ReferenceType.JOB); - refund.setReferenceId(jobId.toString()); - refund.setPolicyId(row.getPolicyId()); - refund.setBillingCategory(category); - ledgerRepository.save(refund); - // Hand back the free units this job consumed (first-step failures are - // pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly - // what was taken at charge time, so the counter can't drift above the grant. - int freeConsumed = - row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed(); - if (freeConsumed > 0 && row.getTeamId() != null) { - teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed); - } - } } } @@ -359,142 +197,6 @@ public class JobChargeService { } } - /** - * Closes a process and — as a fallback — meters its usage. The primary meter trigger is the - * charge interceptor's {@code afterCompletion} on a successful request (see {@link - * #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never - * cleanly completed (request thread died before {@code afterCompletion}) and are swept up later - * by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at - * completion is deduped here at Stripe, so the two paths never double-bill. - * - *

Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which - * silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook - * so a failed POST does not roll back the close; the reconciliation backfill (separate chunk) - * is the durability mechanism. - */ - @Transactional - public ProcessingJob close(UUID jobId) { - Objects.requireNonNull(jobId, "jobId"); - ProcessingJob closed = jobService.close(jobId); - - // The afterCommit hook only fires if there's an active transaction (Spring's - // @Transactional ensures that). If we're called outside one — e.g. a test using the raw - // bean — fall through with a debug log: the close() above already happened in a - // sub-transaction created by JobService, but the surrounding scope has no synchronization. - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - log.debug("close({}): no active synchronization; skipping meter POST", jobId); - return closed; - } - - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - try { - meterJobUsage(jobId); - } catch (RuntimeException e) { - // PaygMeterReportingService should already swallow; defence in depth so - // a thrown exception out of afterCommit doesn't leak past the - // synchronization boundary and bubble into the caller. - log.warn( - "afterCommit meter post for job {} threw unexpectedly: {}", - jobId, - e.getMessage()); - } - } - }); - - return closed; - } - - /** - * Post this job's billable usage to Stripe. The primary caller is the charge interceptor's - * {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes — - * so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code - * afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the - * request thread died); the deterministic idempotency key ({@code process::close}) makes - * the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's - * later stale-closed. - * - *

Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already - * committed by the time either caller runs) and the POST is best-effort. Never throws — see - * {@link PaygMeterReportingService}. - * - *

Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed), - * BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still - * within the app-side free allowance. - */ - public void meterJobUsage(UUID jobId) { - Optional rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId); - if (rowOpt.isEmpty()) { - // No shadow row → not a PAYG-tracked job; nothing to meter. - return; - } - PaygShadowCharge row = rowOpt.get(); - if (row.getStatus() == ShadowChargeStatus.REFUNDED) { - // Refunded rows are zero-net charges; do not emit a meter event. - return; - } - BillingCategory category = row.getBillingCategory(); - if (category == null || category == BillingCategory.BYPASSED) { - // Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before - // openProcess), but tolerate if a future caller writes one. - log.debug("close({}): shadow row category={} → no meter event", jobId, category); - return; - } - Integer units = row.getPaygUnits(); - if (units == null || units <= 0) { - return; - } - Long teamId = row.getTeamId(); - if (teamId == null) { - return; - } - PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null); - if (ext == null) { - return; - } - // payg_subscription_id is the single switch that says "this team is billed" (see - // PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a - // Stripe customer but no live subscription — e.g. the brief window after checkout but - // before the subscription-created webhook lands — must not post meter events against a - // subscription that doesn't exist. A job finishing in that window is still metered later - // via the stale-close fallback, once the subscription has landed (same idempotency key). - String subscriptionId = ext.getPaygSubscriptionId(); - if (subscriptionId == null || subscriptionId.isBlank()) { - log.debug( - "close({}): team {} has no active subscription → no meter event", - jobId, - teamId); - return; - } - String stripeCustomerId = ext.getStripeCustomerId(); - if (stripeCustomerId == null || stripeCustomerId.isBlank()) { - // Subscribed but no customer id is a data inconsistency — we can't address the event. - log.warn( - "close({}): team {} has a subscription but no stripeCustomerId → cannot meter", - jobId, - teamId); - return; - } - - // Paid portion = units beyond the team's one-time free grant, fixed at charge time. The - // free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the - // free units were already withheld when this row's free_units_consumed was set. - int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed(); - int paidUnits = units - freeConsumed; - if (paidUnits <= 0) { - log.debug( - "close({}): all {} units came from the free grant → no meter event", - jobId, - units); - return; - } - String idempotencyKey = "process:" + jobId + ":close"; - meterReportingService.recordUsage( - teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId); - } - /** * Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays * advanced (workflow window intentionally remains active for the next retry). No shadow-row diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java deleted file mode 100644 index 54ebd24c0..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ /dev/null @@ -1,357 +0,0 @@ -package stirling.software.saas.payg.entitlement; - -import java.io.IOException; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.AnnotationUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -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.HandlerInterceptor; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; - -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.payg.cap.AiToolRoutes; -import stirling.software.saas.payg.cap.RequiresFeature; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.util.AuthenticationUtils; - -/** - * Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short- - * circuits the request before any handler work happens when the team's snapshot is missing one of - * the gates the route declared via {@link RequiresFeature}. - * - *

Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping} - * (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated - * routes). Admin / info / config endpoints are excluded by the path-pattern in {@code - * PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non- - * billable infra never trips the guard. - * - *

Decision matrix: - * - * - * - * - * - * - * - *
authrequired gatessnapshot enabled?outcome
anonymousAUTOMATION or AI_SUPPORTn/a401 SIGNUP_REQUIRED
anonymousOFFSITE_PROCESSING / CLIENT_SIDEn/a200 (pass through)
authenticatedrequired ⊆ enabledyes200
authenticatedrequired ⊄ enabledno402 FEATURE_DEGRADED
- * - *

Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap - * pipeline must never block a customer because the guard tripped on a transient DB error. - */ -@Slf4j -@Component -@Profile("saas") -public class EntitlementGuard implements HandlerInterceptor { - - private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING}; - - private final EntitlementService entitlementService; - private final UserRepository userRepository; - private final ObjectMapper objectMapper; - - private final Counter passCounter; - private final Counter deniedDegradedCounter; - private final Counter deniedPaygLimitCounter; - private final Counter deniedSignupRequiredCounter; - private final Counter errorsCounter; - private final Counter skippedNoAnnotationCounter; - - public EntitlementGuard( - EntitlementService entitlementService, - UserRepository userRepository, - MeterRegistry meterRegistry) { - this.entitlementService = entitlementService; - this.userRepository = userRepository; - this.objectMapper = new ObjectMapper(); - - this.passCounter = - Counter.builder("payg.entitlement.guard") - .tag("outcome", "pass") - .register(meterRegistry); - this.deniedDegradedCounter = - Counter.builder("payg.entitlement.guard") - .tag("outcome", "denied_degraded") - .register(meterRegistry); - this.deniedPaygLimitCounter = - Counter.builder("payg.entitlement.guard") - .tag("outcome", "denied_payg_limit") - .register(meterRegistry); - this.deniedSignupRequiredCounter = - Counter.builder("payg.entitlement.guard") - .tag("outcome", "denied_signup_required") - .register(meterRegistry); - this.skippedNoAnnotationCounter = - Counter.builder("payg.entitlement.guard") - .tag("outcome", "skipped") - .register(meterRegistry); - this.errorsCounter = - Counter.builder("payg.entitlement.guard.errors") - .description("EntitlementGuard internal failures (fail-open)") - .register(meterRegistry); - } - - @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) { - if (!(handler instanceof HandlerMethod hm)) { - return true; - } - // Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly - // declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping). - // Admin / info / config endpoints carry neither annotation and never trip the guard. - boolean hasAutoJobPostMapping = - AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null - || AnnotationUtils.findAnnotation( - hm.getBeanType(), AutoJobPostMapping.class) - != null; - boolean hasRequiresFeature = - AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null - || AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class) - != null; - // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't carry - // @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see - // AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI. - boolean aiToolRoute = AiToolRoutes.matches(request); - if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) { - skippedNoAnnotationCounter.increment(); - return true; - } - - FeatureGate[] required = - aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm); - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - boolean anonymous = isAnonymous(auth); - boolean billable = isBillable(required); - - if (anonymous) { - if (billable) { - return write401SignupRequired(response, required); - } - // Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only - // charges authenticated requests. - passCounter.increment(); - return true; - } - - Long teamId; - try { - teamId = resolveTeamId(auth); - } catch (RuntimeException e) { - log.warn("EntitlementGuard resolveTeamId failed; passing through", e); - errorsCounter.increment(); - return true; - } - if (teamId == null) { - // Defensive: authenticated principal with no team — shouldn't happen post-migration, - // but we don't want to lock those users out. PaygChargeInterceptor short-circuits the - // same shape upstream. - passCounter.increment(); - return true; - } - - EntitlementSnapshot snapshot; - try { - snapshot = entitlementService.getSnapshot(teamId); - } catch (RuntimeException e) { - log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e); - errorsCounter.increment(); - return true; - } - - // API-key calls are always billable usage (BillingCategory.API) — there is no "free - // manual" path for a programmatic client the way there is for a JWT/web user, whose - // everyday tool calls are BYPASSED and never reach a gate. So once the team is over its - // free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of - // which gate the route declares. The gate loop below would otherwise wave through an API - // call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED), - // letting an unsubscribed team keep consuming the API for free past its allowance. - if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) { - return write402PaygLimitReached(response, snapshot); - } - - List enabled = snapshot.enabledGates(); - for (FeatureGate gate : required) { - if (enabled == null || !enabled.contains(gate)) { - return write402FeatureDegraded(response, required, snapshot); - } - } - passCounter.increment(); - return true; - } - - static FeatureGate[] resolveRequiredGates(HandlerMethod hm) { - RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class); - if (ann == null) { - ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class); - } - if (ann != null && ann.value().length > 0) { - return ann.value(); - } - return DEFAULT_REQUIRED_GATES; - } - - private static boolean isAnonymous(Authentication auth) { - if (auth == null || !auth.isAuthenticated()) { - return true; - } - // Spring's anonymous filter installs a token whose name is "anonymousUser". - return "anonymousUser".equals(auth.getName()); - } - - private static boolean isBillable(FeatureGate[] required) { - for (FeatureGate g : required) { - if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) { - return true; - } - } - return false; - } - - private Long resolveTeamId(Authentication auth) { - if (auth instanceof ApiKeyAuthenticationToken - && auth.getPrincipal() instanceof User apiUser) { - return apiUser.getTeam() == null ? null : apiUser.getTeam().getId(); - } - String supabaseId = AuthenticationUtils.extractSupabaseId(auth); - if (supabaseId == null) { - return null; - } - UUID supabaseUuid; - try { - supabaseUuid = UUID.fromString(supabaseId); - } catch (IllegalArgumentException e) { - // Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip. - return null; - } - return userRepository - .findBySupabaseId(supabaseUuid) - .map(u -> u.getTeam() == null ? null : u.getTeam().getId()) - .orElse(null); - } - - private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) { - deniedSignupRequiredCounter.increment(); - Map body = new LinkedHashMap<>(); - body.put("error", "SIGNUP_REQUIRED"); - body.put("category", inferCategory(required)); - writeJson(response, HttpStatus.UNAUTHORIZED, body); - return false; - } - - /** - * 402 for a billable API-key call once the team is over its allowance / cap. The message is - * tailored by subscription state: an un-subscribed team is told to subscribe (their free - * allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic - * clients get a stable {@code error} code plus the spend/cap numbers so they can surface - * something actionable. - */ - private boolean write402PaygLimitReached( - HttpServletResponse response, EntitlementSnapshot snapshot) { - deniedPaygLimitCounter.increment(); - Map body = new LinkedHashMap<>(); - body.put("error", "PAYG_LIMIT_REACHED"); - body.put("subscribed", snapshot.subscribed()); - body.put( - "message", - snapshot.subscribed() - ? "Your team has reached its monthly spending cap. Raise the cap to" - + " continue, or wait for it to reset next billing period." - : "Your team has used its free document allowance." - + " Subscribe to continue using the API."); - body.put("state", snapshot.state().name()); - body.put("spendUnits", snapshot.periodSpendUnits()); - body.put("capUnits", snapshot.periodCapUnits()); - body.put( - "periodEnd", - Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null)); - writeJson(response, HttpStatus.PAYMENT_REQUIRED, body); - return false; - } - - private boolean write402FeatureDegraded( - HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) { - deniedDegradedCounter.increment(); - Map body = new LinkedHashMap<>(); - body.put("error", "FEATURE_DEGRADED"); - // subscribed tells the client which usage-limit modal to show: a subscribed team is over - // its spending cap; an un-subscribed one has spent its free allowance. (PAYG_LIMIT_REACHED - // already carries this; mirror it here so the JWT/web path can pick the right modal too.) - body.put("subscribed", snapshot.subscribed()); - body.put("missingGates", missingGates(required, snapshot.enabledGates())); - body.put("state", snapshot.state().name()); - body.put( - "periodEnd", - Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null)); - body.put("capUnits", snapshot.periodCapUnits()); - body.put("spendUnits", snapshot.periodSpendUnits()); - writeJson(response, HttpStatus.PAYMENT_REQUIRED, body); - return false; - } - - private static List missingGates(FeatureGate[] required, List enabled) { - List enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled; - return Arrays.stream(required) - .filter(g -> !enabledOrEmpty.contains(g)) - .map(Enum::name) - .toList(); - } - - private static String inferCategory(FeatureGate[] required) { - // Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI. - for (FeatureGate g : required) { - if (g == FeatureGate.AUTOMATION) { - return "AUTOMATION"; - } - } - for (FeatureGate g : required) { - if (g == FeatureGate.AI_SUPPORT) { - return "AI"; - } - } - return "OFFSITE_PROCESSING"; - } - - private void writeJson( - HttpServletResponse response, HttpStatus status, Map body) { - response.setStatus(status.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - try { - byte[] payload = objectMapper.writeValueAsBytes(body); - response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length)); - response.getOutputStream().write(payload); - response.getOutputStream().flush(); - } catch (IOException e) { - // Container will fall back to its default error page — we did set the status code, - // so the client still sees the right HTTP code even if the body fails to write. - log.warn("EntitlementGuard write response body failed", e); - errorsCounter.increment(); - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java deleted file mode 100644 index 397a714bf..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java +++ /dev/null @@ -1,182 +0,0 @@ -package stirling.software.saas.payg.entitlement; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.YearMonth; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.saas.payg.billing.TeamBillingContext; -import stirling.software.saas.payg.billing.TeamBillingService; -import stirling.software.saas.payg.cap.CapEvaluator; -import stirling.software.saas.payg.cap.CapEvaluator.Evaluation; -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureSet; -import stirling.software.saas.payg.repository.WalletLedgerRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.wallet.WalletPolicy; - -/** - * Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing - * facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service - * layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top. - * - *

Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value - * #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on - * every instance within that window without coordination. Mutators (wallet policy admin updates, - * subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry - * immediately on the originating instance. - */ -@Slf4j -@Service -@Profile("saas") -public class EntitlementService { - - static final int CACHE_TTL_SECONDS = 30; - private static final int CACHE_MAX_SIZE = 10_000; - - private static final int WARN_AT_PCT = 80; - private static final int DEGRADE_AT_PCT = 100; - - private final TeamBillingService teamBillingService; - private final WalletPolicyRepository walletPolicyRepository; - private final WalletLedgerRepository ledgerRepository; - - private final Cache snapshotCache; - - public EntitlementService( - TeamBillingService teamBillingService, - WalletPolicyRepository walletPolicyRepository, - WalletLedgerRepository ledgerRepository) { - this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService"); - this.walletPolicyRepository = - Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository"); - this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); - this.snapshotCache = - Caffeine.newBuilder() - .maximumSize(CACHE_MAX_SIZE) - .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS)) - .recordStats() - .build(); - } - - /** - * Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value - * #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger. - * - *

{@code null} teamId throws — the guard short-circuits team-less requests upstream so a - * null reach here is a programming error. - */ - public EntitlementSnapshot getSnapshot(Long teamId) { - Objects.requireNonNull(teamId, "teamId"); - return snapshotCache.get(teamId, this::computeSnapshot); - } - - /** - * Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers), - * cap edits, or manual ledger adjustments so the next read recomputes immediately rather than - * waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute - * together with the spend. - */ - public void invalidate(Long teamId) { - if (teamId != null) { - snapshotCache.invalidate(teamId); - teamBillingService.invalidate(teamId); - } - } - - /** Visible for tests. */ - long cacheSize() { - return snapshotCache.estimatedSize(); - } - - @Transactional(readOnly = true) - EntitlementSnapshot computeSnapshot(Long teamId) { - TeamBillingContext billing = teamBillingService.forTeam(teamId); - Optional walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId); - - FeatureSet degradedSet = - walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL); - int warnAtPct = - walletPolicyOpt - .map(WalletPolicy::getWarnAtPct) - .filter(Objects::nonNull) - .orElse(WARN_AT_PCT); - int degradeAtPct = - walletPolicyOpt - .map(WalletPolicy::getDegradeAtPct) - .filter(Objects::nonNull) - .orElse(DEGRADE_AT_PCT); - - // Subscription-anchored window when subscribed; calendar month otherwise. Used for the - // subscribed monthly cap + the displayed billing period. - LocalDateTime periodStart = billing.periodStart(); - LocalDateTime periodEnd = billing.periodEnd(); - - Evaluation eval; - long snapshotSpend; - Long snapshotCap; - - if (billing.subscribed()) { - // Subscribed: gate on the monthly spending cap. Spend = this period's net billable - // documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time - // free grant doesn't gate a paying team — it only reduced what they were metered. - long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd); - long periodSpend = signedNet < 0 ? -signedNet : 0L; - Long cap = billing.monthlyCapDocUnits(); - eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet); - snapshotSpend = periodSpend; - snapshotCap = cap; - } else { - // Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or - // no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate - // the warn/degrade band on used-of-grant. - long grant = billing.freeGrantUnits(); - long remaining = billing.freeRemainingUnits(); - long used = Math.max(0L, grant - remaining); - if (remaining <= 0L) { - eval = - new Evaluation( - EntitlementState.DEGRADED, - degradedSet, - CapEvaluator.gatesFor(degradedSet)); - } else { - eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet); - } - snapshotSpend = used; - snapshotCap = grant; - } - - return new EntitlementSnapshot( - eval.state(), - eval.featureSet(), - List.copyOf(eval.enabledGates()), - snapshotSpend, - snapshotCap, - periodStart, - periodEnd, - billing.subscribed()); - } - - /** - * Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a - * clock value so tests don't race the calendar boundary. The live snapshot window comes from - * {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work. - */ - static LocalDateTime[] currentMonthWindow(LocalDateTime now) { - YearMonth ym = YearMonth.from(now); - LocalDateTime start = ym.atDay(1).atStartOfDay(); - LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(); - return new LocalDateTime[] {start, end}; - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java deleted file mode 100644 index b3d44f3c7..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java +++ /dev/null @@ -1,46 +0,0 @@ -package stirling.software.saas.payg.entitlement; - -import java.time.LocalDateTime; -import java.util.List; - -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; - -/** - * Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link - * EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}. - * - *

Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the persisted - * snapshot that the recompute path writes (one row per team, optionally per member). This record is - * the computed-now view the hot-path guard reads — backed by a 30s Caffeine cache so a - * request burst doesn't hammer the ledger SUM. - * - * @param state aggregate state — FULL, WARNED, or DEGRADED. - * @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED). - * @param enabledGates the gates the guard checks against — request proceeds only if every required - * gate is in this list. - * @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical - * doc-units (positive). - * @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code - * wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped. - * @param periodStart inclusive start of the current cap period. - * @param periodEnd exclusive end of the current cap period. - * @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a - * billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team - * that hit its self-set spending cap is told to raise it. - */ -public record EntitlementSnapshot( - EntitlementState state, - FeatureSet featureSet, - List enabledGates, - long periodSpendUnits, - Long periodCapUnits, - LocalDateTime periodStart, - LocalDateTime periodEnd, - boolean subscribed) { - - public boolean isDegraded() { - return state == EntitlementState.DEGRADED; - } -} 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..66aaf5ca7 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 @@ -12,7 +12,6 @@ import java.util.Optional; import java.util.UUID; import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.AnnotationUtils; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -38,15 +37,11 @@ import stirling.software.common.util.TempFileManager; 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.payg.cap.AiToolRoutes; -import stirling.software.saas.payg.cap.RequiresFeature; import stirling.software.saas.payg.charge.ChargeContext; import stirling.software.saas.payg.charge.ChargeOutcome; import stirling.software.saas.payg.charge.JobChargeService; import stirling.software.saas.payg.charge.JobInput; import stirling.software.saas.payg.job.JobService; -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.JobStepStatus; import stirling.software.saas.payg.model.ProcessType; @@ -57,13 +52,10 @@ import stirling.software.saas.util.AuthenticationUtils; * after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste * work hashing inputs. * - *

{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the - * latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the - * parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link - * JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome} - * plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes - * without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge - * service. + *

{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts, + * materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to + * open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed + * as request attributes for {@code afterCompletion}. * *

{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT * lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or @@ -113,7 +105,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { private final Counter callsOpened; private final Counter callsJoined; private final Counter callsShortCircuit; - private final Counter callsBypassed; private final Counter refundsCounter; /** preHandle wall-clock per request. Separate from afterCompletion — different populations. */ @@ -153,11 +144,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { Counter.builder("payg.filter.calls") .tag("disposition", "SHORT_CIRCUIT") .register(meterRegistry); - this.callsBypassed = - Counter.builder("payg.filter.bypassed") - .description( - "Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)") - .register(meterRegistry); this.refundsCounter = Counter.builder("payg.filter.refunds") .description("First-step 5xx refunds applied to shadow rows") @@ -182,42 +168,13 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { if (!properties.isEnabled()) { return true; } - if (!(handler instanceof HandlerMethod hm)) { + if (!(handler instanceof HandlerMethod hm) + || hm.getMethodAnnotation(AutoJobPostMapping.class) == null) { callsShortCircuit.increment(); return true; } - // In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR - // @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of - // these the interceptor short-circuits — admin / info / static routes never run - // determineCategory. - boolean hasAutoJobPostMapping = - AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null - || AnnotationUtils.findAnnotation( - hm.getBeanType(), AutoJobPostMapping.class) - != null; - boolean hasRequiresFeature = - AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null - || AnnotationUtils.findAnnotation( - hm.getBeanType(), RequiresFeature.class) - != null; - // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't - // carry @RequiresFeature, so they're recognised by path — see AiToolRoutes. - boolean aiToolRoute = AiToolRoutes.matches(request); - if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) { - callsShortCircuit.increment(); - return true; - } - // Bypass fast-path: determine the BillingCategory BEFORE any multipart - // materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the - // entire ledger/shadow pipeline — no temp files, no DB writes. - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - BillingCategory category = determineCategory(hm, request, auth); - if (category == BillingCategory.BYPASSED) { - callsBypassed.increment(); - return true; - } try { - doPreHandle(request, auth, category); + doPreHandle(request); } catch (RuntimeException e) { log.warn("PAYG preHandle failed; passing through unbilled", e); errorsCounter.increment(); @@ -230,8 +187,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } } - private void doPreHandle( - HttpServletRequest request, Authentication auth, BillingCategory category) { + private void doPreHandle(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); User currentUser = resolveUser(auth); if (currentUser == null) { callsShortCircuit.increment(); @@ -291,8 +248,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { currentUser.getId(), currentUser.getTeam() == null ? null : currentUser.getTeam().getId(), determineSource(request, auth), - ProcessType.SINGLE_TOOL, - category); + ProcessType.SINGLE_TOOL); ChargeOutcome outcome; try { @@ -375,38 +331,12 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } if (status >= 400) { // 4xx: customer paid for the attempt. No OUTPUT recording, no refund. - // Still a successful-from-billing-standpoint OPENED process — meter it below. - meterIfOpened(jobId, disposition); return; } - // Success: this is the moment the billable work finished, so this is when we tell Stripe. - // Only the OPENED request meters — JOINED follow-up steps (chained tools on the same - // document) added no units and must not re-meter. The process stays OPEN for further - // lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared - // idempotency key. metering is best-effort and must never break the response teardown. - meterIfOpened(jobId, disposition); recordOutputs(request, response, jobId); } - /** - * Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs - * on the request-teardown thread (the response is already flushed to the client); {@code - * meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a - * meter hiccup can't disturb lineage/cleanup that follows. - */ - private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) { - if (disposition != ChargeOutcome.Disposition.OPENED) { - return; - } - try { - chargeService.meterJobUsage(jobId); - } catch (RuntimeException e) { - log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage()); - errorsCounter.increment(); - } - } - private void recordOutputs( HttpServletRequest request, HttpServletResponse response, UUID jobId) { PaygResponseBodyWrapper wrapper = @@ -515,54 +445,6 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { return JobSource.WEB; } - /** - * Resolve the {@link BillingCategory} for this request. Precedence: {@code - * X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION; - * {@code @RequiresFeature(AI_SUPPORT)} → AI; an AI document-tool route ({@link AiToolRoutes}) → - * AI; API-key auth → API; otherwise BYPASSED (manual UI tool — short-circuited in {@link - * #preHandle}). - * - *

Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION - * dominates AI within a single annotation. The AI-tool path check sits below the automation - * header on purpose: an AI tool dispatched inside a policy / AI workflow bills as AUTOMATION, - * while a direct call to it bills as AI. - */ - private static BillingCategory determineCategory( - HandlerMethod handler, HttpServletRequest request, Authentication auth) { - String automationHeader = request.getHeader(AUTOMATION_HEADER); - if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { - return BillingCategory.AUTOMATION; - } - RequiresFeature ann = - AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class); - if (ann == null) { - ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class); - } - if (ann != null) { - boolean ai = false; - for (FeatureGate gate : ann.value()) { - if (gate == FeatureGate.AUTOMATION) { - return BillingCategory.AUTOMATION; - } - if (gate == FeatureGate.AI_SUPPORT) { - ai = true; - } - } - if (ai) { - return BillingCategory.AI; - } - } - // AI document tools (proprietary module, recognised by path). A direct call bills as AI; an - // orchestrator-dispatched call already returned AUTOMATION above via the automation header. - if (AiToolRoutes.matches(request)) { - return BillingCategory.AI; - } - if (auth instanceof ApiKeyAuthenticationToken) { - return BillingCategory.API; - } - return BillingCategory.BYPASSED; - } - /** * Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route * pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups 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..8b29874bc 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 @@ -9,8 +9,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; -import stirling.software.saas.payg.entitlement.EntitlementGuard; - /** * Wires the PAYG filter + interceptor into Spring MVC. Two registrations: * @@ -30,7 +28,6 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard; public class PaygWebMvcConfig implements WebMvcConfigurer { private final PaygChargeInterceptor paygChargeInterceptor; - private final EntitlementGuard entitlementGuard; @Bean public FilterRegistrationBean @@ -42,27 +39,13 @@ 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. + * Interceptor ordering: the legacy {@code UnifiedCreditInterceptor} (registered with default + * order = 0 in {@code CreditInterceptorConfig}) must run BEFORE this one so credit rejections + * short-circuit before we hash inputs. Explicit positive order guarantees this regardless of + * {@code WebMvcConfigurer} bean discovery order. */ public static final int INTERCEPTOR_ORDER = 1000; - /** - * The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in - * ascending order on the way in and skips a later interceptor's {@code preHandle} (and its - * {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request - * the guard refuses (over its free allowance / spending cap, or with no subscription to bill) - * short-circuits with its 402 before the charge interceptor ever runs. A blocked request - * therefore never opens a process, materialises inputs, or writes a charge: a refused operation - * must not bill, and running the guard first guarantees that structurally rather than by - * compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default - * order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection - * still wins. - */ - public static final int ENTITLEMENT_GUARD_ORDER = 900; - @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(paygChargeInterceptor) @@ -73,14 +56,5 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { "/api/v1/info/**", "/api/v1/admin/**") .order(INTERCEPTOR_ORDER); - - registry.addInterceptor(entitlementGuard) - .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/v1/credits/**", - "/api/v1/config/**", - "/api/v1/info/**", - "/api/v1/admin/**") - .order(ENTITLEMENT_GUARD_ORDER); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java index 5addb5b76..e6a4f04a8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java @@ -229,20 +229,6 @@ public class JobService { return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED); } - /** - * Open a standalone process with no lineage inputs, for a billable action that isn't - * file/lineage-driven (e.g. an AI Create session). Because no input signatures are recorded, - * nothing downstream can lineage-join it — each such charge stands alone. {@code docUnits} is - * persisted so the charge service's shadow + ledger rows agree with the job. - */ - @Transactional - public ProcessingJob open(JobContext ctx, int docUnits) { - Objects.requireNonNull(ctx, "ctx"); - ProcessingJob job = openFresh(ctx, Map.of()).job(); - job.setDocUnits(docUnits); - return jobRepository.save(job); - } - private JoinOrOpenResult openFresh( JobContext ctx, Map> signaturesByInput) { ProcessingJob fresh = new ProcessingJob(); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java index 432965aa7..6c4813988 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java @@ -1,69 +1,34 @@ package stirling.software.saas.payg.job; -import java.util.List; - import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.saas.payg.charge.JobChargeService; - /** * Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs * every minute. API users never have to call {@code close()} explicitly — this scheduler is the - * safety net, and (for metered teams) the point at which the Stripe meter event is posted. - * - *

Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)} - * rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code - * JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to - * Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter - * them — usage would accrue in the wallet ledger yet never reach the customer's invoice. - * - *

Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own - * transaction (cross-bean proxied call from this non-transactional scheduled method), so the - * afterCommit meter POST fires once per job and one job's failure can't abort the rest of the - * sweep. The meter event's idempotency key ({@code process::close}) makes a re-run on the next - * tick safe even if a close half-completed. + * safety net. * *

Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other - * {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is - * tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a - * double-fire across pods reads a shrinking stale set and never double-bills. + * {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod + * double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design + * § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate + * firings read an empty stale set on the second pod, no data corruption risk. */ @Component @Profile("saas") +@RequiredArgsConstructor @Slf4j public class StaleJobCloser { private final JobService jobService; - private final JobChargeService chargeService; - - public StaleJobCloser(JobService jobService, JobChargeService chargeService) { - this.jobService = jobService; - this.chargeService = chargeService; - } @Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}") public void closeStale() { - List stale = jobService.findStale(); - if (stale.isEmpty()) { - return; - } - int closed = 0; - for (ProcessingJob job : stale) { - try { - // Routes through the charge service so the afterCommit meter hook fires for - // metered teams. Idempotent: a job already closed by a racing tick no-ops. - chargeService.close(job.getId()); - closed++; - } catch (RuntimeException e) { - // Isolate per job — a single bad row (or a transient meter-path issue) must not - // strand the rest of the stale set open. Next tick retries. - log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage()); - } - } + int closed = jobService.closeStale(); if (closed > 0) { log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java deleted file mode 100644 index e19cf522e..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java +++ /dev/null @@ -1,69 +0,0 @@ -package stirling.software.saas.payg.meter; - -import java.time.LocalDateTime; -import java.util.UUID; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** - * Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log}, - * V15). A row is written pending ({@code posted_to_stripe_at} NULL) just before the POST - * and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows - * still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is - * the durability mechanism behind the fail-open meter path, so a Stripe blip never silently - * under-bills. - * - *

{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code - * process::close}); the unique constraint gives safe at-least-once semantics across the dual - * meter triggers (completion + stale-close) and reconcile retries. - */ -@Entity -@Table(name = "payg_meter_event_log") -@Getter -@Setter -@NoArgsConstructor -public class PaygMeterEventLog { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "event_id") - private Long eventId; - - @Column(name = "team_id", nullable = false) - private Long teamId; - - @Column(name = "job_id") - private UUID jobId; - - @Column(name = "idempotency_key", nullable = false, unique = true, length = 128) - private String idempotencyKey; - - @Column(name = "units", nullable = false) - private Integer units; - - /** - * Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code - * insertPending}). - */ - @Column(name = "occurred_at", nullable = false, insertable = false, updatable = false) - private LocalDateTime occurredAt; - - /** NULL while pending; stamped when the meter-payg-units edge fn returns success. */ - @Column(name = "posted_to_stripe_at") - private LocalDateTime postedToStripeAt; - - @Column(name = "stripe_error_code", length = 64) - private String stripeErrorCode; - - @Column(name = "stripe_error_body", columnDefinition = "text") - private String stripeErrorBody; -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java deleted file mode 100644 index 81cde551f..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java +++ /dev/null @@ -1,139 +0,0 @@ -package stirling.software.saas.payg.meter; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.data.domain.PageRequest; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.saas.payg.policy.PaygTeamExtensions; -import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; - -/** - * Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability - * half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code - * payg_meter_event_log} row before each POST and stamps it on success; anything left unposted - * (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent - * under the same idempotency key, so Stripe dedups rather than double-charging. - * - *

Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no - * longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than - * risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link - * stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a - * duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe — - * idempotent, wasted IO at worst. - */ -@Component -@Profile("saas") -@Slf4j -public class PaygMeterReconcileScheduler { - - /** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */ - private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24); - - private final PaygMeterEventLogRepository eventLogRepository; - private final PaygTeamExtensionsRepository teamExtensionsRepository; - private final PaygMeterReportingService meterReportingService; - private final boolean enabled; - private final Duration retryDelay; - private final int batchSize; - private final Counter retriedCounter; - - public PaygMeterReconcileScheduler( - PaygMeterEventLogRepository eventLogRepository, - PaygTeamExtensionsRepository teamExtensionsRepository, - PaygMeterReportingService meterReportingService, - @Value("${payg.meter.reconcile.enabled:true}") boolean enabled, - @Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay, - @Value("${payg.meter.reconcile.batch-size:100}") int batchSize, - MeterRegistry meterRegistry) { - this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository"); - this.teamExtensionsRepository = - Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository"); - this.meterReportingService = - Objects.requireNonNull(meterReportingService, "meterReportingService"); - this.enabled = enabled; - this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay"); - this.batchSize = batchSize > 0 ? batchSize : 100; - this.retriedCounter = - Counter.builder("payg.meter.reconcile.retried") - .description("PAYG meter events re-posted to Stripe by the reconcile job") - .register(meterRegistry); - } - - @Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC") - public void reconcile() { - if (!enabled) { - return; - } - LocalDateTime now = LocalDateTime.now(); - // Give the live POST a moment to land before retrying; stay inside the 24h dedup window. - LocalDateTime cutoff = now.minus(retryDelay); - LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW); - - List retryable = - eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize)); - - // Batch-fetch this page's team extensions in one query (keyed by team id) rather than a - // findById per row — avoids an N+1 when the page spans several teams. - List teamIds = - retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList(); - Map extById = - teamExtensionsRepository.findAllById(teamIds).stream() - .collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext)); - - int retried = 0; - for (PaygMeterEventLog row : retryable) { - PaygTeamExtensions ext = extById.get(row.getTeamId()); - if (ext == null) { - continue; - } - String subscriptionId = ext.getPaygSubscriptionId(); - String stripeCustomerId = ext.getStripeCustomerId(); - if (subscriptionId == null - || subscriptionId.isBlank() - || stripeCustomerId == null - || stripeCustomerId.isBlank()) { - // Team unsubscribed since the event was logged — nothing to bill; leave the row. - continue; - } - // Same idempotency key → Stripe dedups if the original actually landed. recordUsage - // re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is - // not re-derived (analytics metadata only); units + key are what bill. - meterReportingService.recordUsage( - row.getTeamId(), - stripeCustomerId, - row.getUnits() == null ? 0 : row.getUnits(), - null, - row.getIdempotencyKey(), - row.getJobId()); - retried++; - } - if (retried > 0) { - retriedCounter.increment(retried); - log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried); - } - - long stuck = eventLogRepository.countStuck(floor); - if (stuck > 0) { - log.warn( - "{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —" - + " manual reconciliation needed.", - stuck, - STRIPE_IDEMPOTENCY_WINDOW.toHours()); - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java deleted file mode 100644 index e578edd15..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java +++ /dev/null @@ -1,221 +0,0 @@ -package stirling.software.saas.payg.meter; - -import java.util.Map; -import java.util.UUID; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; - -/** - * POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from - * {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the - * customer's authoritative bill) is already durable before we tell Stripe about it. - * - *

Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for - * analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call - * entirely; the ledger entry is the only record needed. - * - *

Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log - * WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code - * payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link - * PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code - * close()} must not roll back because Stripe wobbled. - * - *

Both config keys default to empty so unit tests / local dev never crash on missing env. When - * blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to - * touch the real edge function. - */ -@Service -@Profile("saas") -@Slf4j -public class PaygMeterReportingService { - - private final String endpoint; - private final String authToken; - private final RestTemplate restTemplate; - private final PaygMeterEventLogRepository eventLogRepository; - private final Counter errorsCounter; - - /** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */ - private static final int MAX_ERROR_BODY = 4000; - - public PaygMeterReportingService( - @Value("${payg.meter.endpoint:}") String endpoint, - @Value("${payg.meter.auth-token:}") String authToken, - RestTemplate saasRestTemplate, - PaygMeterEventLogRepository eventLogRepository, - MeterRegistry meterRegistry) { - this.endpoint = endpoint; - this.authToken = authToken; - this.restTemplate = saasRestTemplate; - this.eventLogRepository = eventLogRepository; - this.errorsCounter = - Counter.builder("payg.meter.errors") - .description("Failures POSTing PAYG meter events to Supabase edge function") - .register(meterRegistry); - } - - /** - * Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on - * the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g. - * {@code "process::close"}) so a retry, a reconcile replay, or a double-fire from two - * pods never charges twice. - * - *

Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn, - * then stamp the row posted or record the Stripe error. The row is what {@link - * PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently - * dropped. - * - *

Never throws. The wallet ledger entry is the source of truth for what the customer is - * billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile - * retries it. - */ - public void recordUsage( - Long teamId, - String stripeCustomerId, - int units, - BillingCategory category, - String idempotencyKey, - UUID jobId) { - if (endpoint == null || endpoint.isBlank()) { - log.debug( - "payg.meter.endpoint not configured; skipping meter event for team {} key {}", - teamId, - idempotencyKey); - return; - } - if (units <= 0) { - // Zero-unit events would inflate event count without changing the bill — defensive. - log.debug( - "Skipping meter event with units={} for team {} key {}", - units, - teamId, - idempotencyKey); - return; - } - - // Durable pending row before the POST so a failure leaves a record the reconcile scheduler - // can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close - // triggers and reconcile retries all share the key. Best-effort: a logging failure must - // never stop us from actually metering. - try { - eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units); - } catch (Exception e) { - log.warn( - "payg_meter_event_log pending insert failed for key {} (still metering): {}", - idempotencyKey, - e.getMessage()); - } - - PostOutcome outcome = - postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey); - - try { - if (outcome.success()) { - eventLogRepository.markPosted(idempotencyKey); - } else { - eventLogRepository.markFailed( - idempotencyKey, outcome.errorCode(), outcome.errorBody()); - } - } catch (Exception e) { - log.warn( - "payg_meter_event_log result update failed for key {}: {}", - idempotencyKey, - e.getMessage()); - } - } - - /** - * POST the event to the edge fn. Never throws; returns success / the captured Stripe error. - * Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract). - */ - private PostOutcome postToStripe( - Long teamId, - String stripeCustomerId, - int units, - BillingCategory category, - String idempotencyKey) { - try { - HttpHeaders headers = new HttpHeaders(); - if (authToken != null && !authToken.isBlank()) { - headers.setBearerAuth(authToken); - } - headers.setContentType(MediaType.APPLICATION_JSON); - - Map body = - Map.of( - "team_id", - // JSON number — the edge fn type-checks and ignores strings. - teamId == null ? -1L : teamId, - "stripe_customer_id", - stripeCustomerId == null ? "" : stripeCustomerId, - "units", - units, - "idempotency_key", - idempotencyKey, - "metadata", - Map.of("category", category == null ? "UNKNOWN" : category.name())); - - ResponseEntity response = - restTemplate.exchange( - endpoint, - HttpMethod.POST, - new HttpEntity<>(body, headers), - String.class); - - if (response.getStatusCode().is2xxSuccessful()) { - return PostOutcome.ok(); - } - log.warn( - "Meter event POST returned {} for team {} key {}: {}", - response.getStatusCode(), - teamId, - idempotencyKey, - response.getBody()); - errorsCounter.increment(); - return PostOutcome.error( - String.valueOf(response.getStatusCode().value()), response.getBody()); - } catch (Exception e) { - // Catch-all by design: this method MUST NOT propagate. The customer's bill via the - // ledger is correct; we just owe Stripe an event the reconcile scheduler will retry. - log.warn( - "Meter event POST failed for team {} key {}: {}", - teamId, - idempotencyKey, - e.getMessage()); - errorsCounter.increment(); - return PostOutcome.error("exception", e.getMessage()); - } - } - - /** Outcome of one edge-fn POST attempt. */ - private record PostOutcome(boolean success, String errorCode, String errorBody) { - static PostOutcome ok() { - return new PostOutcome(true, null, null); - } - - static PostOutcome error(String code, String body) { - String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code; - String trimmedBody = - body != null && body.length() > MAX_ERROR_BODY - ? body.substring(0, MAX_ERROR_BODY) - : body; - return new PostOutcome(false, trimmedCode, trimmedBody); - } - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java deleted file mode 100644 index 2b1c0fee7..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java +++ /dev/null @@ -1,18 +0,0 @@ -package stirling.software.saas.payg.model; - -/** - * Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG - * stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing. - * - *

Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link - * #BYPASSED} is the default for manual UI tool calls that never hit a billable code path. - * - *

Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream - * relies on {@code ordinal()}. - */ -public enum BillingCategory { - BYPASSED, - API, - AI, - AUTOMATION -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java index c564f8556..7d6f1b402 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java @@ -71,16 +71,6 @@ public class PaygTeamExtensions implements Serializable { @Column(name = "payg_subscription_id", unique = true, length = 128) private String paygSubscriptionId; - /** - * Remaining one-time free documents for this team (the lifetime grant). Seeded from the - * effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger, - * updated in V19); decremented by the charge pipeline when a billable charge is written and - * restored on a first-step refund. Never replenishes; survives subscribing. This counter — not - * the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned. - */ - @Column(name = "free_units_remaining", nullable = false) - private Long freeUnitsRemaining = 0L; - @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java index 958117736..743dcdc8a 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -74,14 +74,18 @@ public class PricingPolicy implements Serializable { private Integer fileUnitCap = 1000; /** - * One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means - * no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after - * subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when - * the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team - * counter is authoritative and this column is only the seed for new teams. + * Free-tier allowance — doc units a team on this policy can consume per cycle before they must + * add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their + * very first tool call until they pay. New-signup defaults will set this to a sensible positive + * value; the special launch policy used by the day-1 legacy migration script can override with + * a different size if product wants the original 10 customers to feel special. + * + *

Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code + * payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass + * this check entirely. */ - @Column(name = "free_tier_units", nullable = false) - private Long freeTierUnits = 0L; + @Column(name = "free_tier_units_per_cycle", nullable = false) + private Long freeTierUnitsPerCycle = 0L; /** * Max tool steps allowed in one process before it splits, keyed by the caller's {@link diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java deleted file mode 100644 index 7d752bd14..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java +++ /dev/null @@ -1,81 +0,0 @@ -package stirling.software.saas.payg.repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import org.springframework.data.domain.Pageable; -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 org.springframework.transaction.annotation.Transactional; - -import stirling.software.saas.payg.meter.PaygMeterEventLog; - -@Repository -public interface PaygMeterEventLogRepository extends JpaRepository { - - /** - * Idempotent pending insert. The two meter triggers (charge-completion + stale-close) share the - * idempotency key, and a reconcile retry re-runs the same path, so {@code ON CONFLICT DO - * NOTHING} keeps the audit row at exactly one per key. {@code occurred_at} defaults to {@code - * CURRENT_TIMESTAMP} at the DB; {@code posted_to_stripe_at} stays NULL until success. - * Transactional because it's called from the non-transactional meter-reporting path. - */ - @Transactional - @Modifying - @Query( - value = - "INSERT INTO payg_meter_event_log (team_id, job_id, idempotency_key, units)" - + " VALUES (:teamId, :jobId, :key, :units)" - + " ON CONFLICT (idempotency_key) DO NOTHING", - nativeQuery = true) - void insertPending( - @Param("teamId") Long teamId, - @Param("jobId") UUID jobId, - @Param("key") String key, - @Param("units") int units); - - /** Stamp an event posted; clears any prior error. No-op if already posted. */ - @Transactional - @Modifying - @Query( - "UPDATE PaygMeterEventLog e" - + " SET e.postedToStripeAt = CURRENT_TIMESTAMP," - + " e.stripeErrorCode = NULL, e.stripeErrorBody = NULL" - + " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL") - int markPosted(@Param("key") String key); - - /** Record the latest Stripe error against a still-pending event. */ - @Transactional - @Modifying - @Query( - "UPDATE PaygMeterEventLog e" - + " SET e.stripeErrorCode = :code, e.stripeErrorBody = :body" - + " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL") - int markFailed( - @Param("key") String key, @Param("code") String code, @Param("body") String body); - - /** - * Events still unposted whose attempt is older than {@code cutoff} (give the live POST time to - * land first) but within {@code floor} — Stripe's 24h idempotency window — so a retry under the - * same key safely dedups rather than risking a double charge. Oldest first. - */ - @Query( - "SELECT e FROM PaygMeterEventLog e" - + " WHERE e.postedToStripeAt IS NULL" - + " AND e.occurredAt < :cutoff AND e.occurredAt >= :floor" - + " ORDER BY e.occurredAt ASC") - List findRetryable( - @Param("cutoff") LocalDateTime cutoff, - @Param("floor") LocalDateTime floor, - Pageable pageable); - - /** Events stuck unposted past the safe retry window — surfaced for manual reconciliation. */ - @Query( - "SELECT COUNT(e) FROM PaygMeterEventLog e" - + " WHERE e.postedToStripeAt IS NULL AND e.occurredAt < :floor") - long countStuck(@Param("floor") LocalDateTime floor); -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java index 8a2911f9c..e953285ee 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java @@ -29,20 +29,4 @@ public interface PaygShadowChargeRepository extends JpaRepository findFirstByJobIdOrderByIdAsc(UUID jobId); - - /** - * Paid (Stripe-metered) documents for a team in a period: {@code SUM(payg_units − - * free_units_consumed)} over CHARGED rows. This is exactly what was reported to Stripe in the - * window, so the wallet's "estimated bill so far" is the metered total × rate. REFUNDED rows - * are excluded. - */ - @Query( - "SELECT COALESCE(SUM(s.paygUnits - s.freeUnitsConsumed), 0) FROM PaygShadowCharge s" - + " WHERE s.teamId = :teamId" - + " AND s.status = stirling.software.saas.payg.model.ShadowChargeStatus.CHARGED" - + " AND s.occurredAt >= :from AND s.occurredAt < :to") - long sumPaidUnits( - @Param("teamId") Long teamId, - @Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java index 35fb6bbf7..3473eeeff 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java @@ -3,40 +3,12 @@ package stirling.software.saas.payg.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -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 jakarta.persistence.LockModeType; - import stirling.software.saas.payg.policy.PaygTeamExtensions; @Repository public interface PaygTeamExtensionsRepository extends JpaRepository { Optional findByStripeCustomerId(String stripeCustomerId); - - /** - * Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time - * free grant atomically. The lock serialises concurrent charges for the same team so - * the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is - * exact — two simultaneous jobs can't both believe they drew from the same remaining unit. - * Different teams never contend; the lock is held only for the {@code openProcess} transaction. - */ - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId") - Optional findByIdForUpdate(@Param("teamId") Long teamId); - - /** - * Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is - * commutative so no lock is needed; the amount restored is exactly what the job consumed, so it - * can never exceed the original grant. - */ - @Modifying - @Query( - "UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units" - + " WHERE e.teamId = :teamId") - int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java index 095f6cb92..2a2a00342 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -14,30 +14,7 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry; @Repository public interface WalletLedgerRepository extends JpaRepository { - /** Most recent entries for the Plan page activity feed. */ - List findTop20ByTeamIdOrderByIdDesc(Long teamId); - - /** - * Per-category debit totals over an arbitrary window, as positive units. Replaces the - * calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed - * teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows - * with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger - * by construction. - */ - @Query( - "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units" - + " FROM WalletLedgerEntry e" - + " WHERE e.teamId = :teamId" - + " AND e.entryType = :entryType" - + " AND e.billingCategory IS NOT NULL" - + " AND e.occurredAt >= :periodStart" - + " AND e.occurredAt < :periodEnd" - + " GROUP BY e.billingCategory") - List sumPeriodAmountByCategory( - @Param("teamId") Long teamId, - @Param("entryType") LedgerEntryType entryType, - @Param("periodStart") LocalDateTime periodStart, - @Param("periodEnd") LocalDateTime periodEnd); + List findByTeamIdOrderByOccurredAtDesc(Long teamId); /** Sum of signed amounts over a team's entries — the wallet's current balance in units. */ @Query( @@ -57,24 +34,6 @@ public interface WalletLedgerRepository extends JpaRepository= :periodStart" - + " AND e.occurredAt < :periodEnd") - long sumPeriodNetBillable( - @Param("teamId") Long teamId, - @Param("periodStart") LocalDateTime periodStart, - @Param("periodEnd") LocalDateTime periodEnd); - /** Per-member period spend (only when the member has a sub-cap configured). */ @Query( "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java index 57aea1a0e..b2b8ed0c2 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -19,8 +19,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ShadowChargeStatus; /** @@ -58,15 +56,6 @@ public class PaygShadowCharge implements Serializable { @Column(name = "payg_units", nullable = false) private Integer paygUnits; - /** - * How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time. - * The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores - * this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19 - * rows and for jobs that consumed no free units (team's grant already exhausted). - */ - @Column(name = "free_units_consumed", nullable = false) - private Integer freeUnitsConsumed = 0; - @Column(name = "legacy_credits_charged", nullable = false) private Integer legacyCreditsCharged; @@ -86,24 +75,6 @@ public class PaygShadowCharge implements Serializable { @Column(name = "refund_reason", length = 128) private String refundReason; - /** - * PAYG analytics axis copied from the request that produced this shadow row. {@code null} for - * pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe - * would meter — the flat-meter assumption holds, this is breakdown metadata. - */ - @Enumerated(EnumType.STRING) - @Column(name = "billing_category", length = 16) - private BillingCategory billingCategory; - - /** - * Caller surface that originated the job (copied from {@code processing_job.source} at write - * time so the row stays self-describing after the job table is pruned). {@code null} for - * pre-V16 rows backfilled when {@code processing_job} is no longer present. - */ - @Enumerated(EnumType.STRING) - @Column(name = "job_source", length = 32) - private JobSource jobSource; - @CreationTimestamp @Column(name = "occurred_at", nullable = false, updatable = false) private LocalDateTime occurredAt; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java deleted file mode 100644 index 5859c2099..000000000 --- a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java +++ /dev/null @@ -1,215 +0,0 @@ -package stirling.software.saas.payg.stripe; - -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -import org.springframework.context.annotation.Profile; -import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; - -import lombok.extern.slf4j.Slf4j; - -/** - * Read-only accessor for the Stripe Sync Engine schema ({@code stripe.*}). Gives the PAYG layer the - * team's real billing window and the per-document rate of the Price its subscription bills against - * - both live in Stripe, mirrored into Postgres by the sync engine, and are NOT duplicated in - * {@code stirling_pdf} (money lives in Stripe). - * - *

PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount} - * carries the rate directly. The free grant is deliberately NOT in Stripe - it's the one-time - * {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered), - * because un-subscribed teams get the same grant and have no Stripe Price at all. - * - *

Defensive by construction: the {@code stripe} schema only exists where the sync engine has run - * (dev + prod Supabase, not unit-test H2). Any {@link DataAccessException} - missing schema, - * missing row, connectivity blip - degrades to {@link Optional#empty()} with a WARN so callers fall - * back to calendar-month windows rather than 500ing the wallet endpoint. - */ -@Slf4j -@Repository -@Profile("saas") -public class StripeSubscriptionDao { - - /** - * Billing window + per-document rate of one subscription. Epoch seconds come back from sync - * engine as INTEGER columns; converted to {@link LocalDateTime} in the system zone so they - * compare cleanly against {@code wallet_ledger.occurred_at} (written by - * {@code @CreationTimestamp} with JVM-local semantics). - * - * @param priceId the Stripe Price the subscription's (sole) item bills against; null if the - * item row hasn't synced yet - * @param currency lower-case ISO 4217 of that Price; null when the price row is missing - * @param perDocMinor per-document rate in minor units (may be fractional via {@code - * unit_amount_decimal}); null when the price row is missing or carries no usable amount - * (e.g. a tiered price, which PAYG doesn't use) - */ - public record SubscriptionBilling( - LocalDateTime periodStart, - LocalDateTime periodEnd, - String priceId, - String status, - String currency, - BigDecimal perDocMinor) {} - - /** - * The per-document rate of a Price looked up directly (not via a subscription) - used to price - * the cap estimate for un-subscribed teams, whose default policy points at Stripe Prices that - * carry the same {@code unit_amount} they'd be billed at on subscribing. - * - * @param priceId the resolved Stripe Price id - * @param currency lower-case ISO 4217 of that Price - * @param perDocMinor per-document rate in minor units (may be fractional); never null - a row - * with no usable amount is filtered out rather than returned with a null rate - */ - public record PriceRate(String priceId, String currency, BigDecimal perDocMinor) {} - - private static final String QUERY = - "SELECT s.current_period_start, s.current_period_end, s.status::text AS status," - + " p.id AS price_id, p.currency AS currency," - + " p.unit_amount AS unit_amount, p.unit_amount_decimal AS unit_amount_decimal" - + " FROM stripe.subscriptions s" - + " LEFT JOIN LATERAL (" - + " SELECT si.price FROM stripe.subscription_items si" - + " WHERE si.subscription = s.id AND COALESCE(si.deleted, false) = false" - + " ORDER BY si.created DESC NULLS LAST LIMIT 1" - + " ) item ON true" - + " LEFT JOIN stripe.prices p ON p.id = item.price" - + " WHERE s.id = ?"; - - private final JdbcTemplate jdbcTemplate; - - public StripeSubscriptionDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); - } - - /** Billing window + rate for {@code subscriptionId}; empty if unsynced or schema absent. */ - public Optional findBilling(String subscriptionId) { - if (subscriptionId == null || subscriptionId.isBlank()) { - return Optional.empty(); - } - try { - List rows = - jdbcTemplate.query( - QUERY, - (rs, i) -> { - long startEpoch = rs.getLong("current_period_start"); - boolean startNull = rs.wasNull(); - long endEpoch = rs.getLong("current_period_end"); - boolean endNull = rs.wasNull(); - if (startNull || endNull) { - return null; - } - return new SubscriptionBilling( - toLocal(startEpoch), - toLocal(endEpoch), - rs.getString("price_id"), - rs.getString("status"), - rs.getString("currency"), - extractRate(rs)); - }, - subscriptionId); - return rows.stream().filter(Objects::nonNull).findFirst(); - } catch (DataAccessException e) { - // Missing stripe schema (sync engine not provisioned) or transient DB issue. The - // caller falls back to a calendar-month window; usage still accrues correctly. - log.warn( - "stripe.subscriptions lookup failed for {}: {}", - subscriptionId, - e.getMessage()); - return Optional.empty(); - } - } - - /** - * Per-document rate of the active {@code stripe.prices} row with the given {@code lookupKey} in - * {@code currency} - the elegant mirror of {@link #findBilling}, reading the same synced table - * by Stripe Price {@code lookup_key} instead of via a subscription. The PAYG layer uses this to - * price the cap estimate for an un-subscribed team: there's no subscription to read a rate off, - * but the PAYG Price (lookup key {@code plan:processor}) carries the very rate they'd be billed - * at. We resolve by lookup_key rather than the default policy's price ids because those aren't - * seeded - the lookup key is the stable, env-agnostic handle (same one the price-lookup edge - * function uses). - * - *

Empty when the {@code stripe} schema is absent (H2 unit tests), no active matching row - * exists, or the row carries no usable per-unit amount (e.g. a tiered price, which PAYG doesn't - * use). Callers degrade to "no estimate" exactly as the subscribed path degrades to a - * calendar-month window. - */ - public Optional findRateByLookupKey(String lookupKey, String currency) { - if (lookupKey == null || lookupKey.isBlank() || currency == null || currency.isBlank()) { - return Optional.empty(); - } - String sql = - "SELECT p.id AS price_id, p.currency AS currency," - + " p.unit_amount AS unit_amount," - + " p.unit_amount_decimal AS unit_amount_decimal" - + " FROM stripe.prices p" - + " WHERE p.lookup_key = ? AND p.currency = ?" - + " AND COALESCE(p.active, true) = true" - + " ORDER BY p.created DESC NULLS LAST LIMIT 1"; - try { - List rows = - jdbcTemplate.query( - sql, - (rs, i) -> { - BigDecimal rate = extractRate(rs); - if (rate == null) { - return null; - } - return new PriceRate( - rs.getString("price_id"), rs.getString("currency"), rate); - }, - lookupKey, - currency.toLowerCase()); - return rows.stream().filter(Objects::nonNull).findFirst(); - } catch (DataAccessException e) { - log.warn( - "stripe.prices rate lookup failed for lookup_key {} / currency {}: {}", - lookupKey, - currency, - e.getMessage()); - return Optional.empty(); - } - } - - /** - * Reads the per-document rate off a {@code stripe.prices} row. Prefers {@code - * unit_amount_decimal} (sub-minor-unit precision, e.g. half-cent rates), falls back to the - * integer {@code unit_amount}, and returns null when neither is usable or the amount is ≤ 0 - * (tiered/zero prices PAYG doesn't bill on). Both queries alias the columns identically so this - * is shared verbatim. - */ - private static BigDecimal extractRate(ResultSet rs) throws SQLException { - BigDecimal rate = null; - String decimal = rs.getString("unit_amount_decimal"); - if (decimal != null && !decimal.isBlank()) { - try { - rate = new BigDecimal(decimal); - } catch (NumberFormatException ignore) { - rate = null; - } - } - if (rate == null) { - long unitAmount = rs.getLong("unit_amount"); - if (!rs.wasNull()) { - rate = BigDecimal.valueOf(unitAmount); - } - } - if (rate != null && rate.signum() <= 0) { - rate = null; - } - return rate; - } - - private static LocalDateTime toLocal(long epochSeconds) { - return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault()); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 4971e296f..90aed63cd 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -22,7 +22,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.LedgerBucket; import stirling.software.saas.payg.model.LedgerEntryType; import stirling.software.saas.payg.model.ReferenceType; @@ -77,15 +76,6 @@ public class WalletLedgerEntry implements Serializable { @Column(name = "stripe_event_id", length = 128) private String stripeEventId; - /** - * PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set - * by the charge interceptor for billable debits. Stripe pricing never reads this — it's a - * single flat meter — so the column stays soft-typed (no NOT NULL, no FK). - */ - @Enumerated(EnumType.STRING) - @Column(name = "billing_category", length = 16) - private BillingCategory billingCategory; - @CreationTimestamp @Column(name = "occurred_at", nullable = false, updatable = false) private LocalDateTime occurredAt; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java index 36c04f09c..b3231224d 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java @@ -42,24 +42,6 @@ public interface TeamMembershipRepository extends JpaRepository findByUserId(@Param("userId") Long userId); - /** - * Resolve the single membership a user belongs to. In the PAYG design every user is owned by - * exactly one team — personal-team-for-new-signups, then optionally migrated when they accept - * an invite (the old personal team is deleted on accept). For diagnostic safety this picks the - * earliest-created row if multiple exist, but in steady state there is exactly one. - * - *

Returns both the team and its role so the PAYG wallet endpoint can answer "what does this - * user see?" in a single query rather than a list-then-filter dance. - * - * @param userId the user ID - * @return the user's primary membership, if any - */ - @Query( - "SELECT tm FROM TeamMembership tm JOIN FETCH tm.team" - + " WHERE tm.user.id = :userId" - + " ORDER BY tm.createdAt ASC") - List findPrimaryMembership(@Param("userId") Long userId); - /** * Find all members with a specific role in a team * @@ -72,16 +54,6 @@ public interface TeamMembershipRepository extends JpaRepository findByTeamIdAndRole( @Param("teamId") Long teamId, @Param("role") TeamRole role); - /** - * Count members with a specific role in a team. Lighter than {@link #findByTeamIdAndRole} when - * only the tally is needed (e.g. last-leader checks) — avoids fetching and join-loading rows. - * - * @param teamId the team ID - * @param role the team role (LEADER or MEMBER) - * @return number of members with that role - */ - long countByTeamIdAndRole(Long teamId, TeamRole role); - /** * Check if a user is a member of a team * 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 index c01f3d034..49a0042b1 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java @@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository { + " 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) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)", nativeQuery = true) int consumeCreditBySupabaseId( @@ -109,7 +109,7 @@ public interface UserCreditRepository extends JpaRepository { + " 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) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + " AND cycle_credits_remaining >= :amount", nativeQuery = true) int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -123,7 +123,7 @@ public interface UserCreditRepository extends JpaRepository { + " 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) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + " AND bought_credits_remaining >= :amount", nativeQuery = true) int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -133,7 +133,7 @@ public interface UserCreditRepository extends JpaRepository { 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)", + + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)", nativeQuery = true) Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -142,7 +142,7 @@ public interface UserCreditRepository extends JpaRepository { 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)", + + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_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/EnhancedJwtAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java index 6991c2bfa..c67845319 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java @@ -6,8 +6,6 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import stirling.software.proprietary.security.model.User; - /** * JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so * downstream code (audit, credit accounting) can avoid re-parsing the JWT every request. @@ -16,35 +14,15 @@ public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken { private final String supabaseId; private final String email; - private final User user; public EnhancedJwtAuthenticationToken( Jwt jwt, Collection authorities, String email, String supabaseId) { - this(jwt, authorities, email, supabaseId, null); - } - - public EnhancedJwtAuthenticationToken( - Jwt jwt, - Collection authorities, - String email, - String supabaseId, - User user) { super(jwt, authorities, email); this.email = email; this.supabaseId = supabaseId; - this.user = user; - } - - /** - * Returns the resolved local {@link User} when available so shared {@code principal instanceof - * User} authorization works under JWT auth; falls back to the decoded Jwt. - */ - @Override - public Object getPrincipal() { - return user != null ? user : super.getPrincipal(); } public String getSupabaseId() { 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..08cdfcf57 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 @@ -155,15 +155,9 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { User user = getOrCreateUser(jwt); - // Full accounts carry the resolved User as principal for shared - // instanceof-User authorization; anonymous sessions keep the raw Jwt. EnhancedJwtAuthenticationToken authToken = new EnhancedJwtAuthenticationToken( - jwt, - user.getAuthorities(), - user.getUsername(), - supabaseId, - isAnonymous(jwt) ? null : user); + jwt, user.getAuthorities(), user.getUsername(), supabaseId); SecurityContextHolder.getContext().setAuthentication(authToken); // Hot path: runs on every authenticated request (>10 per page on a typical SPA), 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 2c44a15c9..feaa83a39 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 @@ -23,10 +23,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; @@ -46,7 +44,6 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; 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; @@ -336,16 +333,6 @@ public class SupabaseSecurityConfig { .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(","))); } - // BearerTokenAuthenticationFilter overwrites the context SupabaseAuthenticationFilter - // built; carry its resolved User across so instanceof-User authorization keeps working. - User user = null; - Authentication existing = SecurityContextHolder.getContext().getAuthentication(); - if (existing instanceof EnhancedJwtAuthenticationToken enhanced - && supabaseId != null - && supabaseId.equals(enhanced.getSupabaseId()) - && enhanced.getPrincipal() instanceof User existingUser) { - user = existingUser; - } - return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId, user); + return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId); } } diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java deleted file mode 100644 index e2f5b65b4..000000000 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java +++ /dev/null @@ -1,32 +0,0 @@ -package stirling.software.saas.security; - -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - -/** - * SaaS policy context: only the LEADER of the current user's team may edit policies, and every user - * is scoped to their own team. Replaces the self-hosted global-admin check, which is meaningless on - * SaaS (a single global admin exists for the whole deployment, never per-org) — and the admin gets - * no cross-team escape: scoping binds them like everyone else. - */ -@Component -@Profile("saas") -@RequiredArgsConstructor -public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuthority { - - private final TeamSecurityExpressions teamSecurity; - - @Override - public boolean canEditPolicies() { - return teamSecurity.isCurrentUserTeamLeader(); - } - - @Override - public Long currentUserTeamId() { - return teamSecurity.currentUserTeamId(); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java index 3dfdf541c..0cc91991c 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java @@ -44,27 +44,6 @@ public class TeamSecurityExpressions { .orElse(false); } - /** Whether the current authenticated user is a {@code LEADER} of their own team. */ - public boolean isCurrentUserTeamLeader() { - User currentUser = getCurrentUser(); - if (currentUser == null || currentUser.getTeam() == null) { - return false; - } - return membershipRepository - .findByTeamIdAndUserId(currentUser.getTeam().getId(), currentUser.getId()) - .map(membership -> membership.getRole() == TeamRole.LEADER) - .orElse(false); - } - - /** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */ - public Long currentUserTeamId() { - User currentUser = getCurrentUser(); - if (currentUser == null || currentUser.getTeam() == null) { - return null; - } - return currentUser.getTeam().getId(); - } - /** Whether the current authenticated user is any kind of member of the given team. */ public boolean isTeamMember(Long teamId) { User currentUser = getCurrentUser(); diff --git a/app/saas/src/main/java/stirling/software/saas/service/CreditBackfillRunner.java b/app/saas/src/main/java/stirling/software/saas/service/CreditBackfillRunner.java new file mode 100644 index 000000000..008a7e937 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/service/CreditBackfillRunner.java @@ -0,0 +1,78 @@ +package stirling.software.saas.service; + +import java.util.List; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; + +/** + * ApplicationRunner that backfills user_credits table for existing users who don't have credit rows + * yet. This prevents existing users from being hard-blocked when the credit system is enabled. + * + *

This runs once at application startup after the database schema is ready. + */ +@Component +@Profile("saas") +@ConditionalOnProperty(name = "credits.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +@Slf4j +public class CreditBackfillRunner implements ApplicationRunner { + + private final UserRepository userRepository; + private final CreditService creditService; + + @Override + @Transactional + public void run(ApplicationArguments args) { + try { + backfillUserCredits(); + } catch (Exception e) { + log.error("Failed to backfill user credits", e); + // Don't throw; this shouldn't prevent app startup + } + } + + private void backfillUserCredits() { + log.info("Starting user credits backfill for existing users..."); + + List usersNeedingCredits = userRepository.findUsersWithApiKeyButNoCredits(); + + if (usersNeedingCredits.isEmpty()) { + log.info( + "No users need credit backfill; all users with API keys already have credit rows"); + return; + } + + log.info("Found {} users with API keys that need credit rows", usersNeedingCredits.size()); + + int backfilled = 0; + for (User user : usersNeedingCredits) { + try { + // Use the existing getOrCreateUserCredits method which handles proper allocation + creditService.getOrCreateUserCredits(user); + backfilled++; + + if (backfilled % 100 == 0) { + log.info("Backfilled credits for {} users so far...", backfilled); + } + } catch (Exception e) { + log.warn( + "Failed to create credits for user {}: {}", + user.getUsername(), + e.getMessage()); + } + } + + log.info("Successfully backfilled user_credits for {} existing users", backfilled); + } +} 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 index 6812fb358..075491bb3 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java +++ b/app/saas/src/main/java/stirling/software/saas/service/CreditResetScheduler.java @@ -2,8 +2,12 @@ package stirling.software.saas.service; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAdjusters; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -43,11 +47,47 @@ public class CreditResetScheduler { } } - // 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. + /** Check for missed resets on application startup */ + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + try { + ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone()); + LocalDateTime now = LocalDateTime.now(configuredZone); + LocalDateTime lastScheduledReset = getMostRecentScheduledReset(now, configuredZone); + + log.info( + "Checking for missed cycle credit resets. Last scheduled: {}, Current: {}", + lastScheduledReset, + now); + + creditService.resetCycleCreditsForAllUsers(lastScheduledReset); + creditService.resetCycleCreditsForAllTeams(lastScheduledReset); + log.info("Catch-up cycle credit reset completed"); + } catch (Exception e) { + log.error("Error during catch-up credit reset", e); + } + } + + /** Get the most recent scheduled reset time based on configured schedule and zone */ + private LocalDateTime getMostRecentScheduledReset(LocalDateTime now, ZoneId configuredZone) { + ZonedDateTime zonedNow = now.atZone(configuredZone); + + // Find the 1st of the current month at the configured time (default 02:00) + ZonedDateTime firstOfMonth = + zonedNow.with(TemporalAdjusters.firstDayOfMonth()) + .withHour(2) + .withMinute(0) + .withSecond(0) + .withNano(0); + + // If it's the 1st and before the reset hour, or if current time is before the 1st at 2 AM, + // go to previous month's 1st + if (zonedNow.isBefore(firstOfMonth)) { + firstOfMonth = firstOfMonth.minusMonths(1); + } + + return firstOfMonth.toLocalDateTime(); + } /** * Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like 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..02bf87e27 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 @@ -303,11 +303,6 @@ public class SaasTeamService { throw new IllegalStateException("Team has no available seats"); } - // Validate: accepting won't orphan a team the user leads or that has a paid plan. - // Accepting moves the user off their current team; leaveTeam already blocks the - // last leader of a team from walking away, so accept must enforce the same rule. - assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser); - // User can only be in one team . leave existing teams before joining new one List existingMemberships = membershipRepository.findByUserId(acceptingUser.getId()); @@ -447,46 +442,6 @@ public class SaasTeamService { teamId); } - /** - * Guard against silently orphaning a team when a user accepts an invite to another one. - * - *

{@link #acceptInvitation} moves a user to the inviting team by first leaving their current - * team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team - * must not be left memberless while still billing. {@link #leaveTeam} already refuses to let - * the last leader walk away; accept took a shortcut around that check, which let a paid team's - * leader join another team and orphan their old team together with its live subscription. - * - *

So: for each non-personal team the user leads as its last leader, block the - * accept. The message points them at the right remedy — cancel the plan if the team is paid, - * otherwise transfer leadership first. - * - * @param user the user attempting to accept an invitation - * @throws IllegalStateException if accepting would orphan a team the user leads - */ - private void assertCanLeaveCurrentTeamsToJoinAnother(User user) { - for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { - Team team = membership.getTeam(); - if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) { - // Personal teams are deleted on accept; non-leaders leaving never orphans a team. - continue; - } - // Only reached for a non-personal team the user leads — at most one such team in the - // one-team-per-user model — so this count runs ~once, not per membership. - if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { - // Another leader remains, so the team keeps an owner. - continue; - } - if (hasActivePaidSubscription(team)) { - throw new IllegalStateException( - "Your team has an active plan and you are its last leader. Cancel the plan" - + " or transfer leadership before joining another team."); - } - throw new IllegalStateException( - "You are the last leader of your team. Transfer leadership before joining" - + " another team."); - } - } - /** * Leave team (self-removal) * diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties index ee8bf80ff..725fe79fe 100644 --- a/app/saas/src/main/resources/application-dev.properties +++ b/app/saas/src/main/resources/application-dev.properties @@ -23,10 +23,3 @@ spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consoli logging.level.stirling.software.saas=DEBUG logging.level.org.springframework.security.oauth2.jwt=WARN logging.level.org.springframework.security.oauth2.server.resource=WARN - -# Supabase meter edge fn the Java backend calls (server-to-server, on job close). -# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same -# shared secret the team-invitation flow uses — no service-role key in the Java env). -# Blank secret → the meter service no-ops with a WARN, so the app still boots. -# The billing portal is NOT here — the FE calls create-customer-portal-session directly. -payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 63ba01695..3676ad7d8 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -35,37 +35,7 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120} app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1 app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:} -# ---------- PAYG meter reporting ---------- -# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService -# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set -# PAYG_METER_ENDPOINT in deployed envs to enable. Compose with the Supabase functions base via -# env (e.g. PAYG_METER_ENDPOINT=$SUPABASE_FUNCTIONS_URL/meter-payg-units) rather than templating -# here — concatenating an empty default would produce a half-valid URL. -# Auth rides the existing backend<->edge-fn shared secret (SUPABASE_EDGE_FUNCTION_SECRET, same -# one team-invitation-email uses) — the backend never holds the RLS-bypassing service-role key. -payg.meter.endpoint=${PAYG_METER_ENDPOINT:} -payg.meter.auth-token=${app.supabase.edge-function-secret:} - -# Reconcile job: retries meter events logged to payg_meter_event_log but never confirmed posted -# (Stripe blip, edge-fn outage, pod crash mid-POST). Re-sends under the same idempotency key so -# Stripe dedups; only within Stripe's 24h window. Defaults are sensible; tune/disable via env. -payg.meter.reconcile.enabled=${PAYG_METER_RECONCILE_ENABLED:true} -payg.meter.reconcile.retry-delay=${PAYG_METER_RECONCILE_RETRY_DELAY:PT5M} -payg.meter.reconcile.batch-size=${PAYG_METER_RECONCILE_BATCH_SIZE:100} -payg.meter.reconcile-cron=${PAYG_METER_RECONCILE_CRON:0 */15 * * * *} - -# ---------- PAYG customer portal ---------- -# The Stripe billing-portal session is minted FE-direct: the Plan page calls the Supabase -# `create-customer-portal-session` edge function with the user's JWT (same pattern as checkout), -# and the function's SECURITY DEFINER RPC enforces team membership. No Java proxy, no backend -# config — return_url host allowlisting lives in the edge fn (PORTAL_ALLOWED_RETURN_HOSTS). - supabase.url=https://${app.supabase.project-ref}.supabase.co spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud} - -# ---------- Multi-tenant scoping ---------- -# Restrict the signing user picker to the caller's team; SaaS must be 'team' -# or unrelated tenants leak emails to each other. -storage.signing.userListScope=team diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql deleted file mode 100644 index f78458a1b..000000000 --- a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql +++ /dev/null @@ -1,58 +0,0 @@ --- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that --- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter --- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing. --- --- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates --- it for new rows going forward. - --- --------------------------------------------------------------------------------------------- --- 1. wallet_ledger.billing_category --- --------------------------------------------------------------------------------------------- -ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL; -COMMENT ON COLUMN wallet_ledger.billing_category IS - 'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.'; - --- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree. -CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period - ON wallet_ledger (team_id, billing_category, occurred_at) - WHERE billing_category IS NOT NULL; - --- --------------------------------------------------------------------------------------------- --- 2. payg_shadow_charge.billing_category + job_source --- --------------------------------------------------------------------------------------------- -ALTER TABLE payg_shadow_charge - ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL, - ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL; - --- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned --- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack --- the column.) -UPDATE payg_shadow_charge sc - SET job_source = pj.source - FROM processing_job pj - WHERE pj.job_id = sc.job_id - AND sc.job_source IS NULL; - --- --------------------------------------------------------------------------------------------- --- 3. pricing_policy_stripe_price.stripe_product_id --- Operator populates this manually per row when seeding new policies. Nullable for backward --- compatibility with existing rows that don't carry a Product reference. --- --------------------------------------------------------------------------------------------- -ALTER TABLE pricing_policy_stripe_price - ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL; - --- --------------------------------------------------------------------------------------------- --- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that --- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the --- partial index above. --- --------------------------------------------------------------------------------------------- -CREATE OR REPLACE VIEW wallet_category_summary AS -SELECT - team_id, - date_trunc('month', occurred_at) AS period_start, - billing_category, - SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited, - COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count -FROM wallet_ledger -WHERE billing_category IS NOT NULL -GROUP BY team_id, date_trunc('month', occurred_at), billing_category; diff --git a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql deleted file mode 100644 index cc6b5d033..000000000 --- a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Consolidate users.supabase_id into users.supabase_auth_id. --- --- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was --- created by the initial Supabase schema migration (Sep 2025) and is referenced --- by every RLS policy in the Supabase side of the world (V14's --- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_* --- SECURITY DEFINER RPCs, etc.). --- --- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id` --- column via Flyway V2 — same purpose, different name. Java's User entity then --- mapped to this new column. The result was a split-brain: --- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL. --- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL. --- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users --- failed every membership check. --- --- This migration: --- 1. Backfills supabase_auth_id from supabase_id where the former is NULL. --- 2. Drops the supabase_id column and its unique index. --- --- The Java User entity has been switched to @Column(name = "supabase_auth_id") --- in the same change-set; this migration assumes the new code is already --- deployed (or will be deployed together with this migration). - --- 1. Backfill the canonical column from the duplicate, where needed. -UPDATE users - SET supabase_auth_id = supabase_id - WHERE supabase_auth_id IS NULL - AND supabase_id IS NOT NULL; - --- 2. Drop the duplicate column. IF EXISTS guards against environments where --- the column was already removed manually. -DROP INDEX IF EXISTS uk_users_supabase_id; -ALTER TABLE users DROP COLUMN IF EXISTS supabase_id; diff --git a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql deleted file mode 100644 index ed62b34e0..000000000 --- a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql +++ /dev/null @@ -1,93 +0,0 @@ --- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant. --- --- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT --- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused. --- --- Mechanics: the grant is tracked as a running counter on the team sidecar --- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing --- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written, --- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is --- no longer the source of truth for the grant and its old rows can be pruned after a retention --- window (separate future job). - --- --------------------------------------------------------------------------------------------- --- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.pricing_policy - RENAME COLUMN free_tier_units_per_cycle TO free_tier_units; - -COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS - 'One-time lifetime free document grant handed to a team on creation (copied into ' - 'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and ' - 'survives subscribing. 0 = no free grant (block / meter from the first document).'; - --- --------------------------------------------------------------------------------------------- --- 2. The running counter on the team sidecar. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_team_extensions - ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS - 'Remaining one-time free documents for this team. Seeded from the effective pricing ' - 'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a ' - 'billable charge is written; restored on a first-step refund. Lifetime — never resets. ' - 'Authoritative source for the free grant (independent of wallet_ledger retention).'; - --- --------------------------------------------------------------------------------------------- --- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes --- any need to SUM the ledger over a team's lifetime. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_shadow_charge - ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS - 'How many of this job''s payg_units came out of the team''s free grant at charge time. ' - 'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many ' - 'units to payg_team_extensions.free_units_remaining.'; - --- --------------------------------------------------------------------------------------------- --- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the --- default policy's grant from minute one. (The trigger itself still points at this function.) --- --------------------------------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining) - VALUES ( - NEW.team_id, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - ) - ON CONFLICT (team_id) DO NOTHING; - RETURN NEW; -END $$; - --- --------------------------------------------------------------------------------------------- --- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed --- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds --- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the --- ledger; after this the counter stands alone. Grant = team override policy, else the default. --- --------------------------------------------------------------------------------------------- - -UPDATE stirling_pdf.payg_team_extensions ext - SET free_units_remaining = GREATEST( - 0, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.policy_id = ext.pricing_policy_id), - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - + COALESCE( - (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl - WHERE wl.team_id = ext.team_id - AND wl.entry_type IN ('DEBIT', 'REFUND')), - 0)); diff --git a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql deleted file mode 100644 index d9dc756e3..000000000 --- a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql +++ /dev/null @@ -1,42 +0,0 @@ --- PAYG launch free grant: give the default pricing policy a real one-time grant. --- --- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12 --- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and --- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from --- the very first billable document). This migration sets the launch grant on the default policy --- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still --- 0, so every then-existing team computed to 0). --- --- The launch value lives on the default policy row; tune it there (or via a future admin surface). --- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never --- clobbered. - --- --------------------------------------------------------------------------------------------- --- 1. Launch grant on the default policy, only where it's still the accidental 0. --- --------------------------------------------------------------------------------------------- -UPDATE stirling_pdf.pricing_policy - SET free_tier_units = 500 - WHERE is_default = TRUE - AND free_tier_units = 0; - --- --------------------------------------------------------------------------------------------- --- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining = --- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to --- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and --- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe. --- --------------------------------------------------------------------------------------------- -UPDATE stirling_pdf.payg_team_extensions ext - SET free_units_remaining = GREATEST( - 0, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.policy_id = ext.pricing_policy_id), - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - + COALESCE( - (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl - WHERE wl.team_id = ext.team_id - AND wl.entry_type IN ('DEBIT', 'REFUND')), - 0)) - WHERE ext.free_units_remaining = 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql deleted file mode 100644 index 857e0bfa6..000000000 --- a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Drop the unused wallet_category_summary view. --- --- V16 created this view to back the wallet's per-category spend breakdown via --- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA --- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this --- view have zero readers. The DAO is deleted in the same change; this drops the dead view. --- --- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on --- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run. - -DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java deleted file mode 100644 index 8433f5e5a..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ /dev/null @@ -1,489 +0,0 @@ -package stirling.software.saas.payg.api; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -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.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.BeforeEach; -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.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -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.User; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest; -import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; -import stirling.software.saas.payg.billing.TeamBillingContext; -import stirling.software.saas.payg.billing.TeamBillingService; -import stirling.software.saas.payg.entitlement.EntitlementService; -import stirling.software.saas.payg.entitlement.EntitlementSnapshot; -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; -import stirling.software.saas.payg.model.LedgerEntryType; -import stirling.software.saas.payg.repository.PaygShadowChargeRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; -import stirling.software.saas.payg.repository.WalletLedgerRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; -import stirling.software.saas.security.EnhancedJwtAuthenticationToken; - -/** - * Pure-Mockito unit tests for {@link PaygWalletController}. Covers the documented role / state - * matrix — free vs subscribed, leader vs member, anonymous — plus the cap update endpoint's - * leader-only enforcement and cache invalidation. - */ -@ExtendWith(MockitoExtension.class) -class PaygWalletControllerTest { - - @Mock private EntitlementService entitlementService; - @Mock private TeamBillingService billingService; - @Mock private TeamMembershipRepository memberRepo; - @Mock private PaygTeamExtensionsRepository extRepo; - @Mock private WalletPolicyRepository policyRepo; - @Mock private WalletLedgerRepository ledgerRepo; - @Mock private PaygShadowChargeRepository shadowRepo; - @Mock private UserRepository userRepository; - - private PaygWalletController controller; - - @BeforeEach - void setUp() { - controller = - new PaygWalletController( - entitlementService, - billingService, - memberRepo, - extRepo, - policyRepo, - ledgerRepo, - shadowRepo, - userRepository); - } - - /** - * Free-team billing context: the one-time grant is fully unused (remaining == grant), no - * subscription facts, no monthly cap. The displayed limit comes from the snapshot, not here. - */ - private static TeamBillingContext freeBilling(long teamFreeGrant) { - LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); - return new TeamBillingContext( - false, - null, - start, - start.plusMonths(1), - teamFreeGrant, - teamFreeGrant, - null, - null, - null, - null); - } - - /** - * Subscribed billing context with a money cap (minor units) and a known per-doc rate. The grant - * is treated as exhausted (remaining 0) — typical for a team that has subscribed; {@code - * monthlyCapDocUnits} is the paid-doc ceiling {@code floor(capMoney / rate)}. - */ - private static TeamBillingContext subscribedBilling( - String subscriptionId, Long capMoneyMinor, Long monthlyCapDocUnits) { - LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); - return new TeamBillingContext( - true, - subscriptionId, - start, - start.plusMonths(1), - 500L, - 0L, - BigDecimal.valueOf(2), - "usd", - capMoneyMinor, - monthlyCapDocUnits); - } - - private void stubEmptyLedgerReads(long teamId) { - when(ledgerRepo.sumPeriodAmountByCategory( - eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) - .thenReturn(List.of()); - when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of()); - } - - // ----------------------------------------------------------------------------------------- - // GET /wallet - // ----------------------------------------------------------------------------------------- - - @Test - void getWallet_freeTier_returnsFreeShape() { - User user = userWithId(7L, UUID.randomUUID()); - Team team = teamWithId(42L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); - when(memberRepo.findPrimaryMembership(7L)) - .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); - when(billingService.forTeam(42L)).thenReturn(freeBilling(500L)); - when(entitlementService.getSnapshot(42L)).thenReturn(snapshot(0L, 500L)); - stubEmptyLedgerReads(42L); - - ResponseEntity resp = - controller.getWallet(jwtAuth(user.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - WalletSnapshotResponse body = resp.getBody(); - assertThat(body).isNotNull(); - assertThat(body.status()).isEqualTo("free"); - assertThat(body.role()).isEqualTo("member"); - assertThat(body.capUsd()).isNull(); - assertThat(body.stripeSubscriptionId()).isNull(); - assertThat(body.noCap()).isFalse(); - assertThat(body.billableUsed()).isZero(); - assertThat(body.billableLimit()).isEqualTo(500); - assertThat(body.freeAllowance()).isEqualTo(500); - assertThat(body.pricePerDocMinor()).isNull(); - assertThat(body.currency()).isNull(); - assertThat(body.estimatedBillMinor()).isNull(); - assertThat(body.members()).isEmpty(); - assertThat(body.recent()).isEmpty(); - assertThat(body.categoryBreakdown().api()).isZero(); - assertThat(body.categoryBreakdown().ai()).isZero(); - assertThat(body.categoryBreakdown().automation()).isZero(); - } - - @Test - void getWallet_subscribedMember_returnsCapAndBreakdownFromLedger() { - User user = userWithId(8L, UUID.randomUUID()); - Team team = teamWithId(99L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); - when(memberRepo.findPrimaryMembership(8L)) - .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); - // $25 cap (2500 minor) at $0.02/doc → 1250 paid docs/month (the one-time grant is a - // separate pool, not added to the cap). - when(billingService.forTeam(99L)) - .thenReturn(subscribedBilling("sub_test_99", 2500L, 1250L)); - // 312 paid (metered) docs this period → estimate 312 × $0.02 = $6.24 (624 minor). - when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L); - when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L)); - when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L)); - when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) - .thenReturn( - List.of( - new Object[] {BillingCategory.API, 110L}, - new Object[] {BillingCategory.AI, 200L}, - new Object[] {BillingCategory.AUTOMATION, 2L})); - when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of()); - - ResponseEntity resp = - controller.getWallet(jwtAuth(user.getSupabaseId())); - - WalletSnapshotResponse body = resp.getBody(); - assertThat(body.status()).isEqualTo("subscribed"); - assertThat(body.role()).isEqualTo("member"); - assertThat(body.capUsd()).isEqualTo(25); - assertThat(body.noCap()).isFalse(); - assertThat(body.billableUsed()).isEqualTo(312); - assertThat(body.spendUnitsThisPeriod()).isEqualTo(312); - assertThat(body.billableLimit()).isEqualTo(1250); - assertThat(body.freeAllowance()).isEqualTo(500); - assertThat(body.pricePerDocMinor()).isEqualByComparingTo(BigDecimal.valueOf(2)); - assertThat(body.currency()).isEqualTo("usd"); - assertThat(body.estimatedBillMinor()).isEqualTo(624L); - assertThat(body.members()).isEmpty(); - assertThat(body.categoryBreakdown().api()).isEqualTo(110); - assertThat(body.categoryBreakdown().ai()).isEqualTo(200); - assertThat(body.categoryBreakdown().automation()).isEqualTo(2); - assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99"); - // Member role → ledger never queried per-user. - verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any()); - } - - @Test - void getWallet_subscribedNoCap_returnsNoCapTrueAndNullLimit() { - User user = userWithId(9L, UUID.randomUUID()); - Team team = teamWithId(11L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); - when(memberRepo.findPrimaryMembership(9L)) - .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); - when(billingService.forTeam(11L)).thenReturn(subscribedBilling("sub_nocap", null, null)); - when(entitlementService.getSnapshot(11L)).thenReturn(snapshot(50L, null)); - stubEmptyLedgerReads(11L); - when(memberRepo.findByTeamId(11L)).thenReturn(List.of()); - - ResponseEntity resp = - controller.getWallet(jwtAuth(user.getSupabaseId())); - - WalletSnapshotResponse body = resp.getBody(); - assertThat(body.status()).isEqualTo("subscribed"); - assertThat(body.role()).isEqualTo("leader"); - assertThat(body.capUsd()).isNull(); - assertThat(body.noCap()).isTrue(); - // Uncapped → no document ceiling to draw a bar against. - assertThat(body.billableLimit()).isNull(); - } - - @Test - void getWallet_leader_populatesMembers() { - User leader = userWithId(10L, UUID.randomUUID()); - User member = userWithId(11L, UUID.randomUUID()); - member.setUsername("alice"); - member.setEmail("alice@example.com"); - Team team = teamWithId(77L); - TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER); - TeamMembership memberRow = membership(team, member, TeamRole.MEMBER); - - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); - when(memberRepo.findPrimaryMembership(10L)).thenReturn(List.of(leaderRow)); - when(billingService.forTeam(77L)).thenReturn(freeBilling(500L)); - when(entitlementService.getSnapshot(77L)).thenReturn(snapshot(0L, 500L)); - stubEmptyLedgerReads(77L); - when(memberRepo.findByTeamId(77L)).thenReturn(List.of(leaderRow, memberRow)); - // Ledger returns signed (negative) debits. - when(ledgerRepo.sumPeriodAmountForMember(eq(77L), any(), any(), any(), any())) - .thenReturn(-42L); - - ResponseEntity resp = - controller.getWallet(jwtAuth(leader.getSupabaseId())); - - WalletSnapshotResponse body = resp.getBody(); - assertThat(body.role()).isEqualTo("leader"); - assertThat(body.members()).hasSize(2); - MemberRow secondMember = - body.members().stream() - .filter(m -> "alice".equals(m.name())) - .findFirst() - .orElseThrow(); - assertThat(secondMember.email()).isEqualTo("alice@example.com"); - assertThat(secondMember.spendUnits()).isEqualTo(42); - } - - @Test - void getWallet_anonymousIsRejected() { - Authentication anon = - new AnonymousAuthenticationToken( - "k", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); - - ResponseEntity resp = controller.getWallet(anon); - - // AuthenticationUtils.getCurrentUser throws SecurityException for "anonymousUser" since it - // has no Supabase id and is not a User principal — the controller maps that to 401. - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - verifyNoInteractions(entitlementService, billingService, memberRepo, extRepo, policyRepo); - } - - @Test - void getWallet_authenticatedNoTeam_returnsEmptyFreeShape() { - User user = userWithId(12L, UUID.randomUUID()); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); - when(memberRepo.findPrimaryMembership(12L)).thenReturn(List.of()); - - ResponseEntity resp = - controller.getWallet(jwtAuth(user.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(resp.getBody().status()).isEqualTo("free"); - assertThat(resp.getBody().members()).isEmpty(); - // Entitlement service must not be queried for a teamless user (avoids null-key NPE). - verifyNoInteractions(entitlementService); - } - - // ----------------------------------------------------------------------------------------- - // PATCH /cap - // ----------------------------------------------------------------------------------------- - - @Test - void updateCap_leaderUpdatesUnitsAndInvalidates() { - User leader = userWithId(20L, UUID.randomUUID()); - Team team = teamWithId(33L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); - when(memberRepo.findPrimaryMembership(20L)) - .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); - when(policyRepo.findByTeamId(33L)).thenReturn(Optional.empty()); - - ResponseEntity resp = - controller.updateCap( - new UpdateCapRequest(40, false), jwtAuth(leader.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); - verify(policyRepo).save(saved.capture()); - // Rate unknown in this test (docCapForMoney → empty) → legacy conversion fallback so - // the cap stays enforced rather than silently lifting. - assertThat(saved.getValue().getCapUnits()).isEqualTo(4000L); // 40 USD * 100 units/USD - assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(4000L); // 40 USD == 4000 cents - verify(entitlementService, times(1)).invalidate(33L); - } - - @Test - void updateCap_withKnownRate_storesDerivedDocCap() { - User leader = userWithId(24L, UUID.randomUUID()); - Team team = teamWithId(36L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); - when(memberRepo.findPrimaryMembership(24L)) - .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); - when(policyRepo.findByTeamId(36L)).thenReturn(Optional.empty()); - TeamBillingContext billing = subscribedBilling("sub_36", null, null); - when(billingService.forTeam(36L)).thenReturn(billing); - // $28 cap at $0.02/doc → 1400 paid documents/month (the grant is a separate pool). - when(billingService.docCapForMoney(billing, 2800L)).thenReturn(Optional.of(1400L)); - - ResponseEntity resp = - controller.updateCap( - new UpdateCapRequest(28, false), jwtAuth(leader.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); - verify(policyRepo).save(saved.capture()); - assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(2800L); - assertThat(saved.getValue().getCapUnits()).isEqualTo(1400L); - verify(entitlementService).invalidate(36L); - } - - @Test - void updateCap_noCapTrue_clearsCapUnits() { - User leader = userWithId(21L, UUID.randomUUID()); - Team team = teamWithId(34L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); - when(memberRepo.findPrimaryMembership(21L)) - .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); - WalletPolicy existing = new WalletPolicy(); - existing.setTeamId(34L); - existing.setCapUnits(1000L); - existing.setCapSourceMoney(1000L); - when(policyRepo.findByTeamId(34L)).thenReturn(Optional.of(existing)); - - ResponseEntity resp = - controller.updateCap( - new UpdateCapRequest(0, true), jwtAuth(leader.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); - verify(policyRepo).save(saved.capture()); - assertThat(saved.getValue().getCapUnits()).isNull(); - assertThat(saved.getValue().getCapSourceMoney()).isNull(); - verify(entitlementService).invalidate(34L); - } - - @Test - void updateCap_memberIsForbidden() { - User member = userWithId(22L, UUID.randomUUID()); - Team team = teamWithId(35L); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member)); - when(memberRepo.findPrimaryMembership(22L)) - .thenReturn(List.of(membership(team, member, TeamRole.MEMBER))); - - ResponseEntity resp = - controller.updateCap( - new UpdateCapRequest(50, false), jwtAuth(member.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - verify(policyRepo, never()).save(any()); - verify(entitlementService, never()).invalidate(any()); - } - - @Test - void updateCap_noTeam_isForbidden() { - User user = userWithId(23L, UUID.randomUUID()); - when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); - when(memberRepo.findPrimaryMembership(23L)).thenReturn(List.of()); - - ResponseEntity resp = - controller.updateCap( - new UpdateCapRequest(10, false), jwtAuth(user.getSupabaseId())); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - verify(policyRepo, never()).save(any()); - } - - @Test - void updateCap_anonymousIs401() { - Authentication anon = - new AnonymousAuthenticationToken( - "k", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); - - ResponseEntity resp = controller.updateCap(new UpdateCapRequest(10, false), anon); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - verifyNoInteractions(policyRepo, entitlementService); - } - - // ----------------------------------------------------------------------------------------- - // Fixtures - // ----------------------------------------------------------------------------------------- - - private static User userWithId(Long id, UUID supabaseId) { - User u = new User(); - u.setId(id); - u.setSupabaseId(supabaseId); - return u; - } - - private static Team teamWithId(Long id) { - Team t = new Team(); - t.setId(id); - t.setName("t-" + id); - return t; - } - - private static TeamMembership membership(Team team, User user, TeamRole role) { - TeamMembership m = new TeamMembership(); - m.setTeam(team); - m.setUser(user); - m.setRole(role); - return m; - } - - private static EntitlementSnapshot snapshot(long spend, Long cap) { - LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); - LocalDateTime end = start.plusMonths(1); - return new EntitlementSnapshot( - EntitlementState.FULL, - FeatureSet.FULL, - List.of( - FeatureGate.OFFSITE_PROCESSING, - FeatureGate.AUTOMATION, - FeatureGate.AI_SUPPORT, - FeatureGate.CLIENT_SIDE), - spend, - cap, - start, - end, - false); - } - - private static Authentication jwtAuth(UUID supabaseId) { - Jwt jwt = - Jwt.withTokenValue("token") - .header("alg", "RS256") - .claim("sub", supabaseId.toString()) - .claim("email", "user@example.com") - .build(); - return new EnhancedJwtAuthenticationToken( - jwt, List.of(), "user@example.com", supabaseId.toString()); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java deleted file mode 100644 index 66e2eb074..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package stirling.software.saas.payg.billing; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import stirling.software.saas.payg.policy.PaygTeamExtensions; -import stirling.software.saas.payg.policy.PricingPolicy; -import stirling.software.saas.payg.policy.PricingPolicyService; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.stripe.StripeSubscriptionDao; - -/** - * Unit tests for {@link TeamBillingService#forTeam(Long)} — specifically the {@code subscribed} - * determination, which is the single switch both the wallet UI ({@code status}) and the entitlement - * gate read. - * - *

Regression focus: a team is subscribed iff {@code payg_subscription_id} is set. {@code - * payg_unlink_subscription} nulls that column on {@code customer.subscription.deleted} but - * deliberately keeps {@code stripe_customer_id} (for a future re-subscribe), so a cancelled team - * must read as free again. An earlier fallback treated customer-id presence as subscribed, - * which kept every team that ever subscribed pinned to subscribed forever — the cancelled-team bug - * these tests lock down. - */ -class TeamBillingServiceTest { - - private static final long TEAM_ID = 100L; - - private PaygTeamExtensionsRepository extensionsRepository; - private WalletPolicyRepository walletPolicyRepository; - private PricingPolicyService pricingPolicyService; - private StripeSubscriptionDao subscriptionDao; - private TeamBillingService service; - - @BeforeEach - void setUp() { - extensionsRepository = Mockito.mock(PaygTeamExtensionsRepository.class); - walletPolicyRepository = Mockito.mock(WalletPolicyRepository.class); - pricingPolicyService = Mockito.mock(PricingPolicyService.class); - subscriptionDao = Mockito.mock(StripeSubscriptionDao.class); - service = - new TeamBillingService( - extensionsRepository, - walletPolicyRepository, - pricingPolicyService, - subscriptionDao); - - // Default grant so the free-tier fields are populated; individual tests don't depend on it - // beyond the cancelled-team case below, which asserts the grant survives. - PricingPolicy policy = Mockito.mock(PricingPolicy.class); - when(policy.getFreeTierUnits()).thenReturn(500L); - when(pricingPolicyService.getEffectivePolicy(TEAM_ID)).thenReturn(policy); - } - - private PaygTeamExtensions ext(String subscriptionId, String customerId, long freeRemaining) { - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(TEAM_ID); - ext.setPaygSubscriptionId(subscriptionId); - ext.setStripeCustomerId(customerId); - ext.setFreeUnitsRemaining(freeRemaining); - return ext; - } - - @Test - void subscribed_whenSubscriptionIdPresent() { - when(extensionsRepository.findById(TEAM_ID)) - .thenReturn(Optional.of(ext("sub_123", "cus_123", 0L))); - - TeamBillingContext ctx = service.forTeam(TEAM_ID); - - assertThat(ctx.subscribed()).isTrue(); - assertThat(ctx.subscriptionId()).isEqualTo("sub_123"); - } - - /** - * The cancelled-subscription regression: after {@code payg_unlink_subscription} the - * subscription id is null but the Stripe customer id remains. The team must read as NOT - * subscribed (drops to the free-grant gate), and must still surface its remaining free grant. - */ - @Test - void notSubscribed_afterCancellation_whenOnlyCustomerIdRemains() { - when(extensionsRepository.findById(TEAM_ID)) - .thenReturn(Optional.of(ext(null, "cus_123", 120L))); - - TeamBillingContext ctx = service.forTeam(TEAM_ID); - - assertThat(ctx.subscribed()).isFalse(); - assertThat(ctx.subscriptionId()).isNull(); - // The free grant survives cancellation and is what now gates the team. - assertThat(ctx.freeGrantUnits()).isEqualTo(500L); - assertThat(ctx.freeRemainingUnits()).isEqualTo(120L); - // Not subscribed → no monthly paid-doc cap. - assertThat(ctx.monthlyCapDocUnits()).isNull(); - } - - @Test - void notSubscribed_whenNoSubscriptionAndNoCustomer() { - when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, null, 500L))); - - TeamBillingContext ctx = service.forTeam(TEAM_ID); - - assertThat(ctx.subscribed()).isFalse(); - assertThat(ctx.subscriptionId()).isNull(); - } - - @Test - void notSubscribed_whenNoExtensionRow() { - when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); - - TeamBillingContext ctx = service.forTeam(TEAM_ID); - - assertThat(ctx.subscribed()).isFalse(); - assertThat(ctx.subscriptionId()).isNull(); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java deleted file mode 100644 index 2118f8579..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package stirling.software.saas.payg.cap; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -import stirling.software.saas.payg.cap.CapEvaluator.Evaluation; -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; - -class CapEvaluatorTest { - - // --------------------------------------------------------------------------------------- - // evaluate(): single-axis cap evaluation - // --------------------------------------------------------------------------------------- - - @Test - void nullCap_returnsFullStateAndFullGates() { - Evaluation e = CapEvaluator.evaluate(1_000_000L, null, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); - assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); - assertThat(e.enabledGates()) - .containsExactlyInAnyOrder( - FeatureGate.OFFSITE_PROCESSING, - FeatureGate.AUTOMATION, - FeatureGate.AI_SUPPORT, - FeatureGate.CLIENT_SIDE); - } - - @Test - void zeroCap_treatedAsUnlimitedForSafety() { - // Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL). - Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void wellBelowWarn_returnsFull() { - Evaluation e = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); - assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); - } - - @Test - void exactlyAtWarnThreshold_returnsWarned() { - // 80% of 100 = 80; pct = 80 / 100 * 100 = 80 → ≥ warnAtPct=80 → WARNED - Evaluation e = CapEvaluator.evaluate(80L, 100L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.WARNED); - // Warn band keeps the FULL feature set — only flags the FE to show a banner. - assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); - assertThat(e.enabledGates()).hasSize(4); - } - - @Test - void betweenWarnAndDegrade_returnsWarned() { - Evaluation e = CapEvaluator.evaluate(95L, 100L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.WARNED); - assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); - } - - @Test - void exactlyAtDegradeThreshold_returnsDegradedWithConfiguredSet() { - Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); - assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); - // MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; only - // AUTOMATION + AI_SUPPORT are blocked. - assertThat(e.enabledGates()) - .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); - } - - @Test - void overDegradeThreshold_returnsDegradedAndCannotEscalate() { - Evaluation e = CapEvaluator.evaluate(500L, 100L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); - assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); - } - - @Test - void degradedFeatureSetClientOnly_dropsEverythingButClientSide() { - Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.CLIENT_ONLY); - assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); - assertThat(e.featureSet()).isEqualTo(FeatureSet.CLIENT_ONLY); - assertThat(e.enabledGates()).containsExactly(FeatureGate.CLIENT_SIDE); - } - - @Test - void nullDegradedFeatureSet_fallsBackToMinimal() { - Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, null); - assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); - assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); - } - - @Test - void misconfiguredThresholds_treatedAsNoCap() { - // warn > degrade is malformed; the evaluator returns FULL rather than risk wrong - // degradation. The admin write path is supposed to validate this. - Evaluation e = CapEvaluator.evaluate(100L, 100L, 110, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); - - // negative warn → bad config → FULL - Evaluation neg = CapEvaluator.evaluate(100L, 100L, -10, 100, FeatureSet.MINIMAL); - assertThat(neg.state()).isEqualTo(EntitlementState.FULL); - - // zero degrade → bad config → FULL (would otherwise degrade on any spend) - Evaluation zero = CapEvaluator.evaluate(0L, 100L, 80, 0, FeatureSet.MINIMAL); - assertThat(zero.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void zeroSpend_returnsFullEvenIfCapTiny() { - Evaluation e = CapEvaluator.evaluate(0L, 1L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void warnAtZeroPct_warnsImmediately() { - // Edge case: warn-at-0% means any spend → WARNED. Allowed even if quirky. - Evaluation e = CapEvaluator.evaluate(1L, 100L, 0, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.WARNED); - } - - // --------------------------------------------------------------------------------------- - // gatesFor(): mapping FeatureSet → declared gates - // --------------------------------------------------------------------------------------- - - @Test - void gatesFor_full_listsAllFour() { - assertThat(CapEvaluator.gatesFor(FeatureSet.FULL)) - .containsExactlyInAnyOrder( - FeatureGate.OFFSITE_PROCESSING, - FeatureGate.AUTOMATION, - FeatureGate.AI_SUPPORT, - FeatureGate.CLIENT_SIDE); - } - - @Test - void gatesFor_minimal_keepsOffsiteAndClientSide() { - // MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; AUTOMATION + - // AI_SUPPORT are the only gates dropped on degrade. - assertThat(CapEvaluator.gatesFor(FeatureSet.MINIMAL)) - .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); - } - - @Test - void gatesFor_null_returnsEmpty() { - assertThat(CapEvaluator.gatesFor(null)).isEmpty(); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java deleted file mode 100644 index c2d5fcd8b..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package stirling.software.saas.payg.cap; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.core.annotation.AnnotationUtils; - -import stirling.software.saas.ai.controller.AiCreateController; -import stirling.software.saas.ai.controller.AiCreateInternalController; -import stirling.software.saas.ai.controller.AiProxyController; -import stirling.software.saas.payg.model.FeatureGate; - -/** - * Annotation-rollout guard. The saas {@code PaygChargeInterceptor} reads class-level - * {@code @RequiresFeature} via {@link AnnotationUtils#findAnnotation(Class, Class)} to decide - * whether a request bills as {@code AI}, {@code AUTOMATION}, or falls through to the auth-derived - * default. These tests pin the gate on each AI surface so the classification can't silently regress - * to {@code BYPASSED} if someone strips the annotation while refactoring. - * - *

Out of scope: {@code PipelineController} (in core) and {@code PolicyController} (in - * proprietary) — neither module can import {@code @RequiresFeature} from saas without a forbidden - * upward dependency. Their automation classification is enforced via the {@code - * X-Stirling-Automation} header set unconditionally by {@code InternalApiClient.post}; see the - * dedicated test in that module. - */ -class RequiresFeatureAnnotationRolloutTest { - - @Test - void aiCreateController_isClassifiedAsAiSupport() { - RequiresFeature ann = - AnnotationUtils.findAnnotation(AiCreateController.class, RequiresFeature.class); - assertThat(ann).isNotNull(); - assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); - } - - @Test - void aiCreateInternalController_isClassifiedAsAiSupport() { - RequiresFeature ann = - AnnotationUtils.findAnnotation( - AiCreateInternalController.class, RequiresFeature.class); - assertThat(ann).isNotNull(); - assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); - } - - @Test - void aiProxyController_isClassifiedAsAiSupport() { - RequiresFeature ann = - AnnotationUtils.findAnnotation(AiProxyController.class, RequiresFeature.class); - assertThat(ann).isNotNull(); - assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); - } -} 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..29e7041bf 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 @@ -17,18 +17,14 @@ import java.time.LocalDateTime; import java.util.HashMap; 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.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import stirling.software.saas.payg.docs.DocumentClassifier; @@ -37,24 +33,15 @@ import stirling.software.saas.payg.job.JobContext; import stirling.software.saas.payg.job.JobService; import stirling.software.saas.payg.job.JoinOrOpenResult; import stirling.software.saas.payg.job.ProcessingJob; -import stirling.software.saas.payg.meter.PaygMeterReportingService; -import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.JobStatus; -import stirling.software.saas.payg.model.LedgerBucket; -import stirling.software.saas.payg.model.LedgerEntryType; import stirling.software.saas.payg.model.ProcessType; -import stirling.software.saas.payg.model.ReferenceType; import stirling.software.saas.payg.model.ShadowChargeStatus; -import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; import stirling.software.saas.payg.repository.PaygShadowChargeRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.ProcessingJobRepository; -import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.shadow.PaygShadowCharge; -import stirling.software.saas.payg.wallet.WalletLedgerEntry; /** * Exercises {@link JobChargeService} as an orchestrator: policy lookup, step-limit resolution, @@ -67,9 +54,6 @@ class JobChargeServiceTest { private DocumentClassifier classifier; private PaygShadowChargeRepository shadowRepo; private ProcessingJobRepository jobRepo; - private PaygTeamExtensionsRepository teamExtRepo; - private PaygMeterReportingService meterReporter; - private WalletLedgerRepository ledgerRepo; private JobChargeService service; @BeforeEach @@ -79,31 +63,7 @@ class JobChargeServiceTest { classifier = Mockito.mock(DocumentClassifier.class); shadowRepo = Mockito.mock(PaygShadowChargeRepository.class); jobRepo = Mockito.mock(ProcessingJobRepository.class); - teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class); - meterReporter = Mockito.mock(PaygMeterReportingService.class); - ledgerRepo = Mockito.mock(WalletLedgerRepository.class); - // findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless - // a test stubs the sidecar row. The free split is decided at openProcess time now, not at - // close, so the meter tests just set free_units_consumed on the shadow row directly. - service = - new JobChargeService( - jobService, - policyService, - classifier, - shadowRepo, - jobRepo, - teamExtRepo, - meterReporter, - ledgerRepo); - } - - @AfterEach - void tearDown() { - // Defensive: a previous test could have left a fake synchronization registered. Clearing - // ensures isolation when tests run in any order. - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clear(); - } + service = new JobChargeService(jobService, policyService, classifier, shadowRepo, jobRepo); } @Test @@ -120,12 +80,7 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.SINGLE_TOOL, - BillingCategory.API), + new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), List.of(in)); assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.JOINED); @@ -134,7 +89,6 @@ class JobChargeServiceTest { verify(classifier, never()).classify(any(MultipartFile.class), any()); verify(classifier, never()).classify(anyList(), any()); verify(shadowRepo, never()).save(any()); - verify(ledgerRepo, never()).save(any()); } @Test @@ -152,12 +106,7 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.SINGLE_TOOL, - BillingCategory.API), + new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), List.of(in)); assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.OPENED); @@ -177,82 +126,9 @@ class JobChargeServiceTest { // Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up. assertThat(row.getLegacyCreditsCharged()).isZero(); assertThat(row.getDiffPct()).isZero(); - // PAYG analytics axis: billing_category + job_source are copied from the context so the - // row stays self-describing after processing_job rows are pruned. - assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.API); - assertThat(row.getJobSource()).isEqualTo(JobSource.WEB); // Job entity carries the classified docUnits so close-time receipts can render correctly. assertThat(newJob.getDocUnits()).isEqualTo(4); - - // Live ledger DEBIT mirrors the shadow row: same units, stored NEGATIVE per the - // wallet_ledger sign convention, tied back to the job via reference. - ArgumentCaptor ledgerCaptor = - ArgumentCaptor.forClass(WalletLedgerEntry.class); - verify(ledgerRepo).save(ledgerCaptor.capture()); - WalletLedgerEntry debit = ledgerCaptor.getValue(); - assertThat(debit.getTeamId()).isEqualTo(100L); - assertThat(debit.getActorUserId()).isEqualTo(42L); - assertThat(debit.getEntryType()).isEqualTo(LedgerEntryType.DEBIT); - assertThat(debit.getBucket()).isEqualTo(LedgerBucket.CYCLE); - assertThat(debit.getAmountUnits()).isEqualTo(-4); - assertThat(debit.getReferenceType()).isEqualTo(ReferenceType.JOB); - assertThat(debit.getReferenceId()).isEqualTo(newJob.getId().toString()); - assertThat(debit.getPolicyId()).isEqualTo(policy.getId()); - assertThat(debit.getBillingCategory()).isEqualTo(BillingCategory.API); - } - - @Test - void openProcess_bypassedCategory_writesShadowRowButNoLedgerDebit(@TempDir Path tmp) - throws IOException { - // Manual UI work is never billed: the shadow row still lands (comparison audit trail) - // but the live wallet_ledger must stay untouched. - PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(100L)).thenReturn(policy); - ProcessingJob newJob = openJob(UUID.randomUUID()); - when(jobService.joinOrOpen(any(JobContext.class), anyList())) - .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); - when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) - .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); - - service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.SINGLE_TOOL, - BillingCategory.BYPASSED), - List.of(jobInput(tmp, "in.pdf", "application/pdf"))); - - verify(shadowRepo).save(any(PaygShadowCharge.class)); - verify(ledgerRepo, never()).save(any()); - } - - @Test - void openProcess_openedAutomationContext_writesShadowRowWithAutomationCategory( - @TempDir Path tmp) throws IOException { - PricingPolicy policy = - stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10, JobSource.PIPELINE, 20)); - when(policyService.getEffectivePolicy(100L)).thenReturn(policy); - ProcessingJob newJob = openJob(UUID.randomUUID()); - when(jobService.joinOrOpen(any(JobContext.class), anyList())) - .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); - when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) - .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); - - service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.PIPELINE, - ProcessType.AUTOMATION, - BillingCategory.AUTOMATION), - List.of(jobInput(tmp, "in.pdf", "application/pdf"))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); - verify(shadowRepo).save(captor.capture()); - assertThat(captor.getValue().getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION); - assertThat(captor.getValue().getJobSource()).isEqualTo(JobSource.PIPELINE); } @Test @@ -270,12 +146,7 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.AUTOMATION, - BillingCategory.AUTOMATION), + new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.AUTOMATION), List.of(a, b)); assertThat(out.units()).isEqualTo(7); @@ -296,107 +167,12 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.SINGLE_TOOL, - BillingCategory.API), + new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); assertThat(out.units()).isEqualTo(5); } - @Test - void openProcess_drawsFreeGrant_storesSplitAndDecrementsCounter(@TempDir Path tmp) - throws IOException { - // Team has 10 free units left; a 4-unit job draws all 4 from the grant. The shadow row - // records free_units_consumed = 4 (so nothing meters) and the counter drops to 6. - PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(100L)).thenReturn(policy); - ProcessingJob newJob = openJob(UUID.randomUUID()); - when(jobService.joinOrOpen(any(JobContext.class), anyList())) - .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); - when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) - .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setFreeUnitsRemaining(10L); - when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); - - service.openProcess( - new ChargeContext( - 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), - List.of(jobInput(tmp, "in.pdf", "application/pdf"))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); - verify(shadowRepo).save(captor.capture()); - assertThat(captor.getValue().getPaygUnits()).isEqualTo(4); - assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4); - // Counter decremented in-place and persisted. - assertThat(ext.getFreeUnitsRemaining()).isEqualTo(6L); - verify(teamExtRepo).save(ext); - } - - @Test - void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp) - throws IOException { - // Only 3 free units left; a 10-unit job takes the 3 (counter → 0) and the other 7 bill. - PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(100L)).thenReturn(policy); - ProcessingJob newJob = openJob(UUID.randomUUID()); - when(jobService.joinOrOpen(any(JobContext.class), anyList())) - .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); - when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) - .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 10)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setFreeUnitsRemaining(3L); - when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); - - service.openProcess( - new ChargeContext( - 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), - List.of(jobInput(tmp, "in.pdf", "application/pdf"))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); - verify(shadowRepo).save(captor.capture()); - assertThat(captor.getValue().getPaygUnits()).isEqualTo(10); - assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(3); - assertThat(ext.getFreeUnitsRemaining()).isZero(); - verify(teamExtRepo).save(ext); - } - - @Test - void openProcess_exhaustedGrant_storesZeroFreeAndLeavesCounterUntouched(@TempDir Path tmp) - throws IOException { - // Grant already at 0 → nothing free, full units bill, counter not re-saved. - PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(100L)).thenReturn(policy); - ProcessingJob newJob = openJob(UUID.randomUUID()); - when(jobService.joinOrOpen(any(JobContext.class), anyList())) - .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); - when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) - .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 5)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setFreeUnitsRemaining(0L); - when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); - - service.openProcess( - new ChargeContext( - 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), - List.of(jobInput(tmp, "in.pdf", "application/pdf"))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); - verify(shadowRepo).save(captor.capture()); - assertThat(captor.getValue().getFreeUnitsConsumed()).isZero(); - verify(teamExtRepo, never()).save(any()); - } - @Test void openProcess_resolvesStepLimitFromPolicy_perJobSource(@TempDir Path tmp) throws IOException { @@ -411,12 +187,7 @@ class JobChargeServiceTest { .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.PIPELINE, - ProcessType.AUTOMATION, - BillingCategory.AUTOMATION), + new ChargeContext(42L, 100L, JobSource.PIPELINE, ProcessType.AUTOMATION), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(JobContext.class); @@ -440,12 +211,7 @@ class JobChargeServiceTest { .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); service.openProcess( - new ChargeContext( - 42L, - 100L, - JobSource.DESKTOP_APP, - ProcessType.SINGLE_TOOL, - BillingCategory.API), + new ChargeContext(42L, 100L, JobSource.DESKTOP_APP, ProcessType.SINGLE_TOOL), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(JobContext.class); @@ -459,11 +225,7 @@ class JobChargeServiceTest { () -> service.openProcess( new ChargeContext( - 42L, - 100L, - JobSource.WEB, - ProcessType.SINGLE_TOOL, - BillingCategory.API), + 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), List.of())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("inputs must not be empty"); @@ -472,8 +234,9 @@ class JobChargeServiceTest { @Test void markFirstStepFailed_flipsShadowRowAndClosesProcess() { UUID jobId = UUID.randomUUID(); - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); - row.setPolicyId(7L); + PaygShadowCharge row = new PaygShadowCharge(); + row.setJobId(jobId); + row.setStatus(ShadowChargeStatus.CHARGED); when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(java.util.Optional.of(row)); ProcessingJob job = openJob(jobId); when(jobRepo.findById(jobId)).thenReturn(java.util.Optional.of(job)); @@ -487,37 +250,6 @@ class JobChargeServiceTest { assertThat(job.getClosedAt()).isNotNull(); verify(shadowRepo).save(row); verify(jobRepo).save(job); - - // Compensating REFUND entry: positive amount mirroring the openProcess debit, same JOB - // reference so the pair nets to zero for the period. - ArgumentCaptor ledgerCaptor = - ArgumentCaptor.forClass(WalletLedgerEntry.class); - verify(ledgerRepo).save(ledgerCaptor.capture()); - WalletLedgerEntry refund = ledgerCaptor.getValue(); - assertThat(refund.getTeamId()).isEqualTo(100L); - assertThat(refund.getEntryType()).isEqualTo(LedgerEntryType.REFUND); - assertThat(refund.getBucket()).isEqualTo(LedgerBucket.CYCLE); - assertThat(refund.getAmountUnits()).isEqualTo(4); - assertThat(refund.getReferenceType()).isEqualTo(ReferenceType.JOB); - assertThat(refund.getReferenceId()).isEqualTo(jobId.toString()); - assertThat(refund.getPolicyId()).isEqualTo(7L); - assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API); - // This row consumed no free units, so the grant counter is left alone. - verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong()); - } - - @Test - void markFirstStepFailed_withFreeConsumed_restoresGrantToCounter() { - // A first-step failure is pre-meter: nothing billed to Stripe, but the grant moved at - // charge time. The refund must hand exactly those free units back to the team's counter. - UUID jobId = UUID.randomUUID(); - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId))); - - service.markFirstStepFailed(jobId, "first-step-5xx:503"); - - verify(teamExtRepo).restoreFreeUnits(100L, 3L); } @Test @@ -535,8 +267,6 @@ class JobChargeServiceTest { verify(shadowRepo, never()).save(any()); verify(jobRepo, never()).save(any()); - // No double-credit: the REFUND ledger entry only accompanies the CHARGED→REFUNDED flip. - verify(ledgerRepo, never()).save(any()); } @Test @@ -600,350 +330,6 @@ class JobChargeServiceTest { verify(jobRepo, never()).save(any()); } - // --- close() — meter reporting in afterCommit ----------------------------------------------- - - @Test - void close_subscribedTeam_postsMeterEventAfterCommit() { - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setStripeCustomerId("cus_subscribed"); - ext.setPaygSubscriptionId("sub_test"); - when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); - // Row consumed no free units (free_units_consumed = 0) → all 4 are paid and meter. - - withTransactionSynchronization( - () -> { - service.close(jobId); - Mockito.verifyNoInteractions(meterReporter); - }); - - // afterCommit ran on tearDown of withTransactionSynchronization → meter posted now. - verify(meterReporter) - .recordUsage( - 100L, - "cus_subscribed", - 4, - BillingCategory.API, - "process:" + jobId + ":close", - jobId); - } - - @Test - void close_fullyFreeJob_doesNotPostMeterEvent() { - // The free-vs-paid split is fixed at charge time. A job whose 4 units all came from the - // one-time grant (free_units_consumed = 4) has nothing left to meter; the ledger DEBIT - // alone records the usage. - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, 4, BillingCategory.API); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setStripeCustomerId("cus_subscribed"); - ext.setPaygSubscriptionId("sub_test"); - when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - } - - @Test - void close_partiallyFreeJob_metersOnlyThePaidPortion() { - // 20-unit job that drew 10 from the remaining grant at charge time (free_units_consumed = - // 10) → 10 paid units meter to Stripe. - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 20, 10, BillingCategory.AUTOMATION); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setStripeCustomerId("cus_subscribed"); - ext.setPaygSubscriptionId("sub_test"); - when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); - - withTransactionSynchronization(() -> service.close(jobId)); - - verify(meterReporter) - .recordUsage( - 100L, - "cus_subscribed", - 10, - BillingCategory.AUTOMATION, - "process:" + jobId + ":close", - jobId); - } - - @Test - void close_freeTierTeam_doesNotPostMeterEvent() { - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - // No stripe_customer_id → treated as free-tier on this branch (pre-#6532). - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setStripeCustomerId(null); - when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - } - - @Test - void close_noTeamExtensionsRow_doesNotPostMeterEvent() { - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - when(teamExtRepo.findById(100L)).thenReturn(Optional.empty()); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - } - - @Test - void close_refundedShadowRow_doesNotPostMeterEvent() { - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); - row.setStatus(ShadowChargeStatus.REFUNDED); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - Mockito.verifyNoInteractions(teamExtRepo); - } - - @Test - void close_noShadowRow_doesNotPostMeterEvent() { - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.empty()); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - Mockito.verifyNoInteractions(teamExtRepo); - } - - @Test - void close_bypassedCategoryOnShadowRow_doesNotPostMeterEvent() { - // Defensive: BYPASSED rows shouldn't normally exist (the interceptor short-circuits - // before openProcess), but if one slips through we must not meter it. - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.BYPASSED); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - withTransactionSynchronization(() -> service.close(jobId)); - - Mockito.verifyNoInteractions(meterReporter); - Mockito.verifyNoInteractions(teamExtRepo); - } - - @Test - void close_meterReporterThrowsRuntimeException_doesNotPropagate() { - // PaygMeterReportingService is documented to swallow; defence-in-depth in - // JobChargeService catches a misbehaving impl so the afterCommit hook can't poison the - // close flow. - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.AUTOMATION); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); - - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(100L); - ext.setStripeCustomerId("cus_subscribed"); - ext.setPaygSubscriptionId("sub_test"); - when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); - - Mockito.doThrow(new RuntimeException("simulated meter failure")) - .when(meterReporter) - .recordUsage( - Mockito.anyLong(), - Mockito.anyString(), - Mockito.anyInt(), - Mockito.any(BillingCategory.class), - Mockito.anyString(), - Mockito.any(UUID.class)); - - // Should not throw — afterCommit's defence-in-depth wraps the call. - withTransactionSynchronization(() -> service.close(jobId)); - verify(meterReporter) - .recordUsage( - 100L, - "cus_subscribed", - 4, - BillingCategory.AUTOMATION, - "process:" + jobId + ":close", - jobId); - } - - @Test - void close_noActiveTransactionSync_skipsMeterPostButStillClosesJob() { - // Direct call without an outer @Transactional → no sync to register against. close() - // must still close the job; the meter post is implicitly deferred to whatever async path - // eventually wraps the call (or is never made, which is fine for ledger-only flows). - UUID jobId = UUID.randomUUID(); - ProcessingJob job = openJob(jobId); - when(jobService.close(jobId)).thenReturn(job); - - assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); - service.close(jobId); - - Mockito.verifyNoInteractions(meterReporter); - verify(jobService).close(jobId); - } - - // --- chargeStandalone() — non-file billable actions (e.g. AI Create) ----------------------- - - @Test - void chargeStandalone_subscribedTeam_chargesAndMetersPaidPortion() { - // AI Create-style charge: one standalone bookkeeping job, free split, ledger debit, meter. - long teamId = 100L; - PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); - - UUID jobId = UUID.randomUUID(); - when(jobService.open(any(JobContext.class), eq(1))).thenReturn(openJob(jobId)); - when(jobService.close(jobId)).thenReturn(openJob(jobId)); - - // Subscribed, no free grant left → the whole unit is paid and meters. - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(teamId); - ext.setStripeCustomerId("cus_x"); - ext.setPaygSubscriptionId("sub_x"); - ext.setFreeUnitsRemaining(0L); - when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); - when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) - .thenReturn(Optional.of(chargedShadowRow(jobId, teamId, 1, 0, BillingCategory.AI))); - - ChargeContext ctx = - new ChargeContext( - 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.AI); - ArgumentCaptor ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class); - - withTransactionSynchronization(() -> service.chargeStandalone(ctx, 1)); - - verify(jobService).open(any(JobContext.class), eq(1)); - verify(jobService).close(jobId); - verify(ledgerRepo).save(ledger.capture()); - assertThat(ledger.getValue().getEntryType()).isEqualTo(LedgerEntryType.DEBIT); - assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-1); - assertThat(ledger.getValue().getBillingCategory()).isEqualTo(BillingCategory.AI); - verify(shadowRepo).save(any(PaygShadowCharge.class)); - // Paid portion (1) metered to Stripe after commit, keyed by the standard process key. - verify(meterReporter) - .recordUsage( - eq(teamId), - eq("cus_x"), - eq(1), - eq(BillingCategory.AI), - eq("process:" + jobId + ":close"), - eq(jobId)); - } - - @Test - void chargeStandalone_freeTeamWithGrant_drawsGrantAndDoesNotMeter() { - long teamId = 100L; - PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); - when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); - - UUID jobId = UUID.randomUUID(); - when(jobService.open(any(JobContext.class), eq(1))).thenReturn(openJob(jobId)); - when(jobService.close(jobId)).thenReturn(openJob(jobId)); - - // Free grant available, no subscription → the unit is drawn from the grant, nothing meters. - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(teamId); - ext.setFreeUnitsRemaining(50L); - when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); - when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); - when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) - .thenReturn(Optional.of(chargedShadowRow(jobId, teamId, 1, 1, BillingCategory.AI))); - - ChargeContext ctx = - new ChargeContext( - 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.AI); - - withTransactionSynchronization(() -> service.chargeStandalone(ctx, 1)); - - assertThat(ext.getFreeUnitsRemaining()).isEqualTo(49L); - verify(meterReporter, never()) - .recordUsage(any(), any(), Mockito.anyInt(), any(), any(), any()); - } - - @Test - void chargeStandalone_bypassedCategory_throws() { - ChargeContext ctx = - new ChargeContext( - 7L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.BYPASSED); - assertThatThrownBy(() -> service.chargeStandalone(ctx, 1)) - .isInstanceOf(IllegalArgumentException.class); - } - - private static void withTransactionSynchronization(Runnable body) { - TransactionSynchronizationManager.initSynchronization(); - try { - body.run(); - // Drain registered synchronizations to simulate a successful commit. - for (TransactionSynchronization sync : - TransactionSynchronizationManager.getSynchronizations()) { - sync.afterCommit(); - } - } finally { - TransactionSynchronizationManager.clear(); - } - } - - private static PaygShadowCharge chargedShadowRow( - UUID jobId, Long teamId, int units, BillingCategory category) { - return chargedShadowRow(jobId, teamId, units, 0, category); - } - - private static PaygShadowCharge chargedShadowRow( - UUID jobId, Long teamId, int units, int freeUnitsConsumed, BillingCategory category) { - PaygShadowCharge row = new PaygShadowCharge(); - row.setJobId(jobId); - row.setTeamId(teamId); - row.setPaygUnits(units); - row.setFreeUnitsConsumed(freeUnitsConsumed); - row.setStatus(ShadowChargeStatus.CHARGED); - row.setBillingCategory(category); - return row; - } - // --- helpers -------------------------------------------------------------------------------- private static PricingPolicy stubPolicy(int minCharge, Map stepLimits) { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java deleted file mode 100644 index d66e4b17f..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ /dev/null @@ -1,619 +0,0 @@ -package stirling.software.saas.payg.entitlement; - -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.when; - -import java.lang.reflect.Method; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.HashMap; -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.Test; -import org.mockito.Mockito; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.method.HandlerMethod; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.common.annotations.AutoJobPostMapping; -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.User; -import stirling.software.saas.payg.cap.RequiresFeature; -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; -import stirling.software.saas.security.EnhancedJwtAuthenticationToken; - -/** - * Pure-Mockito tests for {@link EntitlementGuard}. Covers the four decision-matrix cells: anonymous - * billable → 401, anonymous manual → pass, authenticated FULL → pass, authenticated DEGRADED for a - * billable route → 402. - */ -class EntitlementGuardTest { - - private EntitlementService entitlementService; - private UserRepository userRepository; - private MeterRegistry meterRegistry; - private EntitlementGuard guard; - - private final ObjectMapper json = new ObjectMapper(); - - @BeforeEach - void setUp() { - entitlementService = Mockito.mock(EntitlementService.class); - userRepository = Mockito.mock(UserRepository.class); - meterRegistry = new SimpleMeterRegistry(); - guard = new EntitlementGuard(entitlementService, userRepository, meterRegistry); - SecurityContextHolder.clearContext(); - } - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - // --------------------------------------------------------------------------------------- - // Scope: non-AutoJobPostMapping routes skip the guard entirely - // --------------------------------------------------------------------------------------- - - @Test - void nonHandlerMethod_passesThrough() throws Exception { - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, "someRawHandler"); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - @Test - void routeWithNeitherAnnotation_isSkipped() throws Exception { - HandlerMethod hm = handlerFor("plainEndpoint"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - Mockito.verifyNoInteractions(entitlementService, userRepository); - } - - @Test - void routeWithRequiresFeatureButNoAutoJobPostMapping_isInScope() throws Exception { - // Regression: @RequiresFeature alone (e.g. JSON-bodied AI controllers) must be in-scope. - // Previously the guard short-circuited unless @AutoJobPostMapping was present, which meant - // a team without AI entitlement could hit /api/v1/ai/* freely. - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); - - HandlerMethod hm = handlerFor("aiOnlyNoAutoJobPostMapping"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); - assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); - verify(entitlementService).getSnapshot(42L); - } - - @Test - void routeWithClassLevelRequiresFeatureOnly_isInScope() throws Exception { - // Mirrors AiCreateController shape: @RequiresFeature lives on the @RestController class, - // not the method. Must still be picked up by the guard. - SecurityContextHolder.getContext() - .setAuthentication( - new AnonymousAuthenticationToken( - "key", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - - HandlerMethod hm = handlerForClassLevel("classLevelAiMethod"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(401); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); - assertThat(body.get("category").asText()).isEqualTo("AI"); - } - - @Test - void aiToolRoute_noAnnotation_isInScopeAndGatedOnAiSupport() throws Exception { - // /api/v1/ai/tools/** controllers live in the proprietary module and carry no - // @RequiresFeature; the guard recognises them by path and gates on AI_SUPPORT. A degraded - // team is 402'd even though the handler has no annotation. - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); - - HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setRequestURI("/api/v1/ai/tools/pdf-comment-agent"); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); - assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); - verify(entitlementService).getSnapshot(42L); - } - - @Test - void aiToolRoute_anonymous_returns401WithAiCategory() throws Exception { - SecurityContextHolder.getContext() - .setAuthentication( - new AnonymousAuthenticationToken( - "key", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - - HandlerMethod hm = handlerFor("plainEndpoint"); - MockHttpServletRequest req = new MockHttpServletRequest(); - req.setRequestURI("/api/v1/ai/tools/math-auditor-agent"); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(401); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); - assertThat(body.get("category").asText()).isEqualTo("AI"); - } - - // --------------------------------------------------------------------------------------- - // Anonymous user - // --------------------------------------------------------------------------------------- - - @Test - void anonymousUser_billableRoute_returns401SignupRequired() throws Exception { - SecurityContextHolder.getContext() - .setAuthentication( - new AnonymousAuthenticationToken( - "key", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - - HandlerMethod hm = handlerFor("automationOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(401); - assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); - assertThat(body.get("category").asText()).isEqualTo("AUTOMATION"); - Mockito.verifyNoInteractions(entitlementService); - } - - @Test - void anonymousUser_aiRoute_returns401WithAiCategory() throws Exception { - SecurityContextHolder.getContext() - .setAuthentication( - new AnonymousAuthenticationToken( - "key", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - - HandlerMethod hm = handlerFor("aiOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("category").asText()).isEqualTo("AI"); - } - - @Test - void anonymousUser_manualTool_passesThroughUnbilled() throws Exception { - SecurityContextHolder.getContext() - .setAuthentication( - new AnonymousAuthenticationToken( - "key", - "anonymousUser", - List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); - - HandlerMethod hm = handlerFor("manualTool"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - Mockito.verifyNoInteractions(entitlementService); - } - - // --------------------------------------------------------------------------------------- - // Authenticated user — FULL vs DEGRADED - // --------------------------------------------------------------------------------------- - - @Test - void authenticatedUser_fullState_passesThrough() throws Exception { - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot()); - - HandlerMethod hm = handlerFor("automationOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - @Test - void authenticatedUser_degradedAndBillable_returns402() throws Exception { - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); - - HandlerMethod hm = handlerFor("automationOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); - assertThat(body.get("state").asText()).isEqualTo("DEGRADED"); - // subscribed drives the client's modal choice (free-limit vs spend-cap). - assertThat(body.get("subscribed").asBoolean()).isFalse(); - assertThat(body.get("capUnits").asLong()).isEqualTo(500L); - assertThat(body.get("spendUnits").asLong()).isEqualTo(500L); - assertThat(body.get("missingGates").isArray()).isTrue(); - assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION"); - } - - @Test - void authenticatedUser_degradedButManualTool_passesThrough() throws Exception { - // KEY assertion: DEGRADED+MINIMAL must still allow manual server tools (OFFSITE_PROCESSING) - // — that's the whole point of moving OFFSITE into MINIMAL. - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); - - HandlerMethod hm = handlerFor("manualTool"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - @Test - void authenticatedUser_aiRouteDegraded_returns402() throws Exception { - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); - - HandlerMethod hm = handlerFor("aiOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); - } - - // --------------------------------------------------------------------------------------- - // API-key calls: billable, hard-stop when degraded (allowance/cap reached) - // --------------------------------------------------------------------------------------- - - @Test - void apiKeyCall_degradedFreeTeam_returns402AndAdvisesSubscribe() throws Exception { - // A plain server tool (OFFSITE_PROCESSING) reached via API key: the gate survives DEGRADED, - // so the gate loop would wave it through — but API usage is billable and must hard-stop - // once the free allowance is spent. - SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false)); - - HandlerMethod hm = handlerFor("manualTool"); // OFFSITE default gate - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED"); - assertThat(body.get("subscribed").asBoolean()).isFalse(); - assertThat(body.get("message").asText()).contains("Subscribe"); - } - - @Test - void apiKeyCall_degradedSubscribedTeam_returns402AndAdvisesCap() throws Exception { - SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(true)); - - HandlerMethod hm = handlerFor("manualTool"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isFalse(); - assertThat(res.getStatus()).isEqualTo(402); - JsonNode body = json.readTree(res.getContentAsByteArray()); - assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED"); - assertThat(body.get("subscribed").asBoolean()).isTrue(); - assertThat(body.get("message").asText()).contains("cap"); - } - - @Test - void apiKeyCall_withinAllowance_passes() throws Exception { - // Not degraded → API usage under the free allowance proceeds normally. - SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); - when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot()); - - HandlerMethod hm = handlerFor("manualTool"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - @Test - void jwtWebManualTool_degraded_stillPasses() throws Exception { - // The "allow JWT web tool usage" guarantee: a web (JWT) user running an everyday server - // tool (OFFSITE_PROCESSING) is NOT blocked when the team is over allowance — only billable - // API/AI/automation calls hard-stop. (Web manual calls are BillingCategory.BYPASSED.) - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false)); - - HandlerMethod hm = handlerFor("manualTool"); // OFFSITE — survives DEGRADED - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - // --------------------------------------------------------------------------------------- - // Fail-open - // --------------------------------------------------------------------------------------- - - @Test - void snapshotLookupThrows_failsOpenAndPasses() throws Exception { - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - when(userRepository.findBySupabaseId(supabaseId)) - .thenReturn(Optional.of(userWithTeam(7L, 42L))); - when(entitlementService.getSnapshot(42L)) - .thenThrow(new RuntimeException("transient DB outage")); - - HandlerMethod hm = handlerFor("automationOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - assertThat(res.getStatus()).isEqualTo(200); - } - - @Test - void noTeam_passesThrough() throws Exception { - UUID supabaseId = UUID.randomUUID(); - SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); - User u = new User(); - u.setId(7L); - u.setTeam(null); - when(userRepository.findBySupabaseId(supabaseId)).thenReturn(Optional.of(u)); - - HandlerMethod hm = handlerFor("automationOnly"); - MockHttpServletRequest req = new MockHttpServletRequest(); - MockHttpServletResponse res = new MockHttpServletResponse(); - - boolean proceed = guard.preHandle(req, res, hm); - - assertThat(proceed).isTrue(); - verify(entitlementService, never()).getSnapshot(any()); - } - - // --------------------------------------------------------------------------------------- - // resolveRequiredGates fallback - // --------------------------------------------------------------------------------------- - - @Test - void resolveRequiredGates_noAnnotation_defaultsToOffsiteProcessing() throws Exception { - HandlerMethod hm = handlerFor("manualTool"); - FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm); - assertThat(gates).containsExactly(FeatureGate.OFFSITE_PROCESSING); - } - - @Test - void resolveRequiredGates_withAnnotation_usesAnnotationValue() throws Exception { - HandlerMethod hm = handlerFor("automationOnly"); - FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm); - assertThat(gates).containsExactly(FeatureGate.AUTOMATION); - } - - // --------------------------------------------------------------------------------------- - // Helpers / fixture controller - // --------------------------------------------------------------------------------------- - - private static EntitlementSnapshot fullSnapshot() { - return new EntitlementSnapshot( - EntitlementState.FULL, - FeatureSet.FULL, - List.of( - FeatureGate.OFFSITE_PROCESSING, - FeatureGate.AUTOMATION, - FeatureGate.AI_SUPPORT, - FeatureGate.CLIENT_SIDE), - 0L, - 500L, - LocalDateTime.of(2026, 6, 1, 0, 0), - LocalDateTime.of(2026, 7, 1, 0, 0), - false); - } - - private static EntitlementSnapshot degradedSnapshot() { - return degradedSnapshot(false); - } - - private static EntitlementSnapshot degradedSnapshot(boolean subscribed) { - return new EntitlementSnapshot( - EntitlementState.DEGRADED, - FeatureSet.MINIMAL, - List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE), - 500L, - 500L, - LocalDateTime.of(2026, 6, 1, 0, 0), - LocalDateTime.of(2026, 7, 1, 0, 0), - subscribed); - } - - private static ApiKeyAuthenticationToken apiKeyAuth(long teamId) { - User u = userWithTeam(99L, teamId); - return new ApiKeyAuthenticationToken( - u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER"))); - } - - private static User userWithTeam(long userId, long teamId) { - User u = new User(); - u.setId(userId); - Team t = new Team(); - t.setId(teamId); - u.setTeam(t); - return u; - } - - private static EnhancedJwtAuthenticationToken jwtAuth(UUID supabaseId) { - Map headers = new HashMap<>(); - headers.put("alg", "RS256"); - Map claims = new HashMap<>(); - claims.put("sub", supabaseId.toString()); - claims.put("email", "user@example.com"); - Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims); - return new EnhancedJwtAuthenticationToken( - jwt, - List.of(new SimpleGrantedAuthority("ROLE_USER")), - "user@example.com", - supabaseId.toString()); - } - - private static HandlerMethod handlerFor(String methodName) throws NoSuchMethodException { - Method m = TestController.class.getDeclaredMethod(methodName); - return new HandlerMethod(new TestController(), m); - } - - private static HandlerMethod handlerForClassLevel(String methodName) - throws NoSuchMethodException { - Method m = ClassLevelAiController.class.getDeclaredMethod(methodName); - return new HandlerMethod(new ClassLevelAiController(), m); - } - - /** Fixture mounting route shapes the guard's resolver needs to discriminate. */ - static class TestController { - - @AutoJobPostMapping("/manual") - public String manualTool() { - return "ok"; - } - - @AutoJobPostMapping("/automation") - @RequiresFeature(FeatureGate.AUTOMATION) - public String automationOnly() { - return "ok"; - } - - @AutoJobPostMapping("/ai") - @RequiresFeature(FeatureGate.AI_SUPPORT) - public String aiOnly() { - return "ok"; - } - - /** Endpoint without any annotation — guard must skip. */ - public String plainEndpoint() { - return "ok"; - } - - /** AI-controller shape: @RequiresFeature with NO @AutoJobPostMapping (JSON body). */ - @RequiresFeature(FeatureGate.AI_SUPPORT) - public String aiOnlyNoAutoJobPostMapping() { - return "ok"; - } - } - - /** - * Mirrors {@code AiCreateController} layout: @RequiresFeature on the class, plain methods. The - * guard must pick up the class-level annotation. - */ - @RequiresFeature(FeatureGate.AI_SUPPORT) - static class ClassLevelAiController { - public String classLevelAiMethod() { - return "ok"; - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java deleted file mode 100644 index 430e1b7e0..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java +++ /dev/null @@ -1,271 +0,0 @@ -package stirling.software.saas.payg.entitlement; - -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.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.LocalDateTime; -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import stirling.software.saas.payg.billing.TeamBillingContext; -import stirling.software.saas.payg.billing.TeamBillingService; -import stirling.software.saas.payg.model.EntitlementState; -import stirling.software.saas.payg.model.FeatureGate; -import stirling.software.saas.payg.model.FeatureSet; -import stirling.software.saas.payg.repository.WalletLedgerRepository; -import stirling.software.saas.payg.repository.WalletPolicyRepository; -import stirling.software.saas.payg.wallet.WalletPolicy; - -/** - * Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 — the free allowance - * is a one-time lifetime grant): - * - *

    - *
  • Unsubscribed — gated by the grant. Cap = grant size, spend = {@code grant − - * remaining}, both read straight from the billing context (no ledger query). Exhausted grant - * (remaining ≤ 0) → DEGRADED. - *
  • Subscribed — gated by the monthly money-derived doc cap. Spend = this period's net - * billable units ({@link WalletLedgerRepository#sumPeriodNetBillable} negated, refunds - * netted). - *
- * - * Also covers cache hit/miss + the invalidate cascade. - */ -class EntitlementServiceTest { - - private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 6, 9, 0, 0); - private static final LocalDateTime PERIOD_END = LocalDateTime.of(2026, 7, 9, 0, 0); - - private TeamBillingService billingService; - private WalletPolicyRepository walletPolicyRepo; - private WalletLedgerRepository ledgerRepo; - private EntitlementService service; - - @BeforeEach - void setUp() { - billingService = Mockito.mock(TeamBillingService.class); - walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class); - ledgerRepo = Mockito.mock(WalletLedgerRepository.class); - service = new EntitlementService(billingService, walletPolicyRepo, ledgerRepo); - } - - @Test - void getSnapshot_nullTeamId_throws() { - assertThatThrownBy(() -> service.getSnapshot(null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void freeTeam_capIsTheGrantAndSpendIsUsedFromCounter() { - // Unsubscribed: cap = grant size, spend = grant − remaining — no ledger read. - stubBilling(42L, freeContext(500L, 400L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.periodCapUnits()).isEqualTo(500L); - assertThat(snap.periodSpendUnits()).isEqualTo(100L); - // 100/500 = 20% — well below warn → FULL - assertThat(snap.state()).isEqualTo(EntitlementState.FULL); - assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); - // The grant gate doesn't touch the ledger at all. - Mockito.verifyNoInteractions(ledgerRepo); - } - - @Test - void subscribedTeam_capIsTheMoneyDerivedDocCap() { - stubBilling(42L, subscribedContext(2000L)); - when(walletPolicyRepo.findByTeamId(42L)) - .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-500L); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.periodCapUnits()).isEqualTo(2000L); - assertThat(snap.periodSpendUnits()).isEqualTo(500L); - // 500/2000 = 25% — FULL - assertThat(snap.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void subscribedTeam_refundsNetAgainstSpend() { - // Net billable = debits − refunds. A −300 net (e.g. 500 debited, 200 refunded) → 300 spend. - stubBilling(42L, subscribedContext(2000L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-300L); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.periodSpendUnits()).isEqualTo(300L); - } - - @Test - void spendWindow_comesFromBillingContextNotCalendarMonth() { - stubBilling(42L, subscribedContext(2000L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - // The subscription-anchored window flows through to both the snapshot and the SUM query. - assertThat(snap.periodStart()).isEqualTo(PERIOD_START); - assertThat(snap.periodEnd()).isEqualTo(PERIOD_END); - verify(ledgerRepo).sumPeriodNetBillable(eq(42L), eq(PERIOD_START), eq(PERIOD_END)); - } - - @Test - void exhaustedGrant_returnsDegradedWithMinimalGates() { - // Grant fully consumed (remaining 0) → billable categories hard-stop for an unsubscribed - // team. The displayed cap stays the grant size; spend reads as the full grant. - stubBilling(42L, freeContext(100L, 0L)); - when(walletPolicyRepo.findByTeamId(42L)) - .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED); - assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL); - assertThat(snap.periodCapUnits()).isEqualTo(100L); - assertThat(snap.periodSpendUnits()).isEqualTo(100L); - // MINIMAL now keeps OFFSITE_PROCESSING + CLIENT_SIDE (manual tools); AUTOMATION + AI gone. - assertThat(snap.enabledGates()) - .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); - assertThat(snap.enabledGates()) - .doesNotContain(FeatureGate.AUTOMATION, FeatureGate.AI_SUPPORT); - } - - @Test - void grantInWarnBand_returnsWarnedButFullFeatureSet() { - // grant 100, remaining 15 → used 85 = 85% (between warn 80 and degrade 100). - stubBilling(42L, freeContext(100L, 15L)); - when(walletPolicyRepo.findByTeamId(42L)) - .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.state()).isEqualTo(EntitlementState.WARNED); - assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); - assertThat(snap.enabledGates()).hasSize(4); - } - - @Test - void uncappedSubscribedTeam_nullCapNeverDegrades() { - stubBilling(42L, subscribedContext(null)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1_000_000L); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.periodCapUnits()).isNull(); - assertThat(snap.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void positiveNetBillable_treatedAsZeroSpend() { - // Subscribed defensive: if refunds exceed debits (positive net), spend clamps to zero. - stubBilling(42L, subscribedContext(100L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(50L); - - EntitlementSnapshot snap = service.getSnapshot(42L); - - assertThat(snap.periodSpendUnits()).isZero(); - assertThat(snap.state()).isEqualTo(EntitlementState.FULL); - } - - @Test - void cacheHit_secondCallSkipsLedgerLookup() { - stubBilling(42L, subscribedContext(500L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); - - service.getSnapshot(42L); - service.getSnapshot(42L); - service.getSnapshot(42L); - - // Only one underlying ledger SUM despite 3 calls — second + third hit the cache. - verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any()); - assertThat(service.cacheSize()).isEqualTo(1); - } - - @Test - void invalidate_dropsCacheAndCascadesToBillingService() { - stubBilling(42L, subscribedContext(500L)); - when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); - - service.getSnapshot(42L); - service.invalidate(42L); - service.getSnapshot(42L); - - verify(ledgerRepo, times(2)).sumPeriodNetBillable(eq(42L), any(), any()); - // Window/cap facts must recompute together with the spend. - verify(billingService).invalidate(42L); - } - - @Test - void invalidate_otherTeamLeavesEntryAlone() { - when(billingService.forTeam(any())).thenReturn(subscribedContext(500L)); - when(walletPolicyRepo.findByTeamId(any())).thenReturn(Optional.empty()); - when(ledgerRepo.sumPeriodNetBillable(any(), any(), any())).thenReturn(0L); - - service.getSnapshot(42L); - service.invalidate(99L); - service.getSnapshot(42L); - - // Only one fetch for team 42 — 99 invalidate didn't touch its entry. - verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any()); - } - - @Test - void currentMonthWindow_isStartOfMonthInclusiveToStartOfNextMonthExclusive() { - LocalDateTime mid = LocalDateTime.of(2026, 6, 15, 14, 30); - LocalDateTime[] w = EntitlementService.currentMonthWindow(mid); - assertThat(w[0]).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0)); - assertThat(w[1]).isEqualTo(LocalDateTime.of(2026, 7, 1, 0, 0)); - } - - private void stubBilling(Long teamId, TeamBillingContext ctx) { - when(billingService.forTeam(teamId)).thenReturn(ctx); - } - - /** Unsubscribed team: gated by the one-time grant (size + remaining); no monthly cap. */ - private static TeamBillingContext freeContext(long grant, long remaining) { - return new TeamBillingContext( - false, null, PERIOD_START, PERIOD_END, grant, remaining, null, null, null, null); - } - - /** - * Subscribed team: monthly money-derived paid-doc cap (null = uncapped); grant treated as - * exhausted (remaining 0 — doesn't gate a paying team). - */ - private static TeamBillingContext subscribedContext(Long monthlyCapDocUnits) { - return new TeamBillingContext( - true, - "sub_test", - PERIOD_START, - PERIOD_END, - 500L, - 0L, - java.math.BigDecimal.valueOf(2), - "usd", - monthlyCapDocUnits == null ? null : monthlyCapDocUnits * 2, - monthlyCapDocUnits); - } - - private static WalletPolicy walletPolicyThresholds(FeatureSet degradedSet) { - WalletPolicy p = new WalletPolicy(); - p.setDegradedFeatureSet(degradedSet); - p.setWarnAtPct(80); - p.setDegradeAtPct(100); - return p; - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index 128c336c3..72af9368e 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -130,8 +130,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_openedDisposition_stashesJobIdAndDisposition() throws Exception { - // API-key auth → BillingCategory.API → billable path engaged. - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 4, ChargeOutcome.Disposition.OPENED)); @@ -154,8 +153,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_chargeServiceThrows_failsOpenAndIncrementsErrorCounter() throws Exception { - // API-key auth → API category → reaches openProcess so the throw can be observed. - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); when(chargeService.openProcess(any(), anyList())).thenThrow(new RuntimeException("boom")); MockMultipartHttpServletRequest req = newMultipart(); @@ -172,7 +170,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_2xx_appendsStepAndRecordsOutputs() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -205,33 +203,11 @@ class PaygChargeInterceptorTest { verify(jobService).recordOutput(eq(jobId), any()); verify(chargeService, never()).markFirstStepFailed(any(), any()); verify(chargeService, never()).decrementStepCount(any()); - // Success on an OPENED process is the primary meter trigger — fires now, not at close. - verify(chargeService).meterJobUsage(jobId); - } - - @Test - void afterCompletion_2xx_joined_doesNotMeter() throws Exception { - // A JOINED follow-up step (chained tool on the same document) added no units when it - // joined — it must not re-meter; the OPENED step already did. - authenticateWithApiKey(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED)); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - MockHttpServletResponse res = new MockHttpServletResponse(); - res.setStatus(200); - - interceptor.preHandle(req, res, handlerMethodForFakeController()); - interceptor.afterCompletion(req, res, handlerMethodForFakeController(), null); - - verify(chargeService, never()).meterJobUsage(any()); } @Test void afterCompletion_5xx_opened_callsMarkFirstStepFailed() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -250,13 +226,11 @@ class PaygChargeInterceptorTest { verify(jobService) .appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("503")); assertThat(meterRegistry.counter("payg.filter.refunds").count()).isEqualTo(1.0); - // First-step failure refunds — never meter it. - verify(chargeService, never()).meterJobUsage(any()); } @Test void afterCompletion_5xx_joined_callsDecrementStepCount() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED)); @@ -275,7 +249,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_4xx_appendsFailedStepNoRefundNoOutputs() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -293,8 +267,6 @@ class PaygChargeInterceptorTest { verify(jobService, never()).recordOutput(any(), any()); verify(jobService) .appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("422")); - // 4xx is a full charge (customer paid for the attempt), so it still meters. - verify(chargeService).meterJobUsage(jobId); } @Test @@ -321,7 +293,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_maxBytesExceeded_skipsOutputRecording() throws Exception { properties.getResponse().setMaxBytes(2L); - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -345,10 +317,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_desktopClientHeader_setsJobSourceDesktopApp() throws Exception { - // API-key (billable) so the call reaches openProcess; the desktop header still wins the - // source mapping (checked before the API-key branch in determineSource). A manual JWT call - // is BYPASSED and never opens a process, so source wouldn't be recorded. - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -369,9 +338,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_toolId_prefersBestMatchingPattern() throws Exception { - // API-key (billable) so doPreHandle runs and records tool_id; a manual JWT call is - // BYPASSED before that point, so no tool_id is stored. - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -392,9 +359,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_toolId_truncatesAndCountsWhenLongerThan128() throws Exception { - // API-key (billable) so doPreHandle runs and records tool_id (a manual JWT call is - // BYPASSED). - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -415,7 +380,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_pipelineHeader_setsJobSourcePipeline() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); + authenticateWithUser(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -434,251 +399,6 @@ class PaygChargeInterceptorTest { .isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE); } - // --- BillingCategory categorisation + bypass fast-path ------------------------------------- - - @Test - void preHandle_manualToolJwt_isBypassedAndSkipsOpenProcess() throws Exception { - // JWT-authenticated, plain @AutoJobPostMapping endpoint, no automation header → BYPASSED. - // The interceptor must skip openProcess entirely (no temp files, no DB writes) and bump - // the payg.filter.bypassed counter. - authenticateWithUser(makeUser(7L, 42L)); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - boolean cont = - interceptor.preHandle( - req, new MockHttpServletResponse(), handlerMethodForFakeController()); - - assertThat(cont).isTrue(); - verify(chargeService, never()).openProcess(any(), anyList()); - assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_JOB_ID)).isNull(); - assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_INPUT_TEMP_FILES)).isNull(); - assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(1.0); - } - - @Test - void preHandle_apiKeyAuth_setsBillingCategoryApi() throws Exception { - authenticateWithApiKey(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.API); - } - - @Test - void preHandle_requiresFeatureAutomation_setsBillingCategoryAutomation() throws Exception { - // JWT auth on a @RequiresFeature(AUTOMATION) endpoint → AUTOMATION category. - authenticateWithUser(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAutomation()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); - } - - @Test - void preHandle_requiresFeatureAiSupport_setsBillingCategoryAi() throws Exception { - // JWT auth on a @RequiresFeature(AI_SUPPORT) endpoint → AI category. - authenticateWithUser(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); - } - - @Test - void preHandle_requiresFeatureWithoutAutoJobPostMapping_reachesCategoryGate() throws Exception { - // Regression: AI controllers carry @RequiresFeature but NO @AutoJobPostMapping. They must - // still flow past the short-circuit gate so determineCategory runs (and so future - // multipart-bearing @RequiresFeature routes bill correctly). API-key auth + - // @RequiresFeature - // — even without multipart inputs — should land in the BillingCategory.API branch via - // determineCategory's auth check, then short-circuit inside doPreHandle because there are - // no multipart parts. - authenticateWithApiKey(makeUser(7L, 42L)); - MockMultipartHttpServletRequest req = newMultipart(); - // No file parts — emulates a JSON-bodied AI controller request that happens to be wrapped - // as multipart. doPreHandle short-circuits with no openProcess call. - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[0])); - - boolean cont = - interceptor.preHandle( - req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob()); - - assertThat(cont).isTrue(); - verify(chargeService, never()).openProcess(any(), anyList()); - // Importantly: not counted as BYPASSED — the AI category was determined correctly. - assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(0.0); - } - - @Test - void preHandle_aiEndpointWithoutAutoJobPostMapping_categoryIsAi() throws Exception { - // With multipart parts present + @RequiresFeature(AI_SUPPORT) but no @AutoJobPostMapping: - // the interceptor must run determineCategory and tag the ChargeContext as AI. - authenticateWithApiKey(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); - } - - @Test - void preHandle_classLevelRequiresFeatureOnly_isInScope() throws Exception { - // Mirrors AiCreateController shape: @RequiresFeature on the @RestController class. - // The interceptor must resolve it via beanType lookup and not short-circuit as - // "no annotation". - authenticateWithApiKey(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForClassLevelAi()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); - } - - @Test - void preHandle_aiEndpointWithAutomationHeader_automationWinsByPrecedence() throws Exception { - // X-Stirling-Automation: true on an @RequiresFeature(AI_SUPPORT) endpoint → AUTOMATION - // (header beats annotation by design — pipeline-driven AI counts as automation usage). - authenticateWithUser(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - req.addHeader("X-Stirling-Automation", "true"); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); - } - - @Test - void preHandle_aiToolRoute_inScopeAndCategoryAi() throws Exception { - // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and carry no PAYG - // annotation. The interceptor recognises them by path → in scope + AI category, so a direct - // multipart call opens a charge. Handler has NO annotations (handlerMethodForPlain). - authenticateWithUser(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.setRequestURI("/api/v1/ai/tools/pdf-comment-agent"); - req.addFile( - new MockMultipartFile("fileInput", "x.pdf", "application/pdf", "abc".getBytes())); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); - } - - @Test - void preHandle_aiToolRoute_withAutomationHeader_isAutomation() throws Exception { - // An AI tool dispatched inside a policy / AI workflow carries X-Stirling-Automation: true → - // AUTOMATION wins over the AI path rule (the header is checked first). - authenticateWithUser(makeUser(7L, 42L)); - UUID jobId = UUID.randomUUID(); - when(chargeService.openProcess(any(), anyList())) - .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); - org.mockito.ArgumentCaptor ctxCaptor = - org.mockito.ArgumentCaptor.forClass( - stirling.software.saas.payg.charge.ChargeContext.class); - - MockMultipartHttpServletRequest req = newMultipart(); - req.setRequestURI("/api/v1/ai/tools/pdf-comment-agent"); - req.addFile( - new MockMultipartFile("fileInput", "x.pdf", "application/pdf", "abc".getBytes())); - req.addHeader("X-Stirling-Automation", "true"); - - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); - - verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); - assertThat(ctxCaptor.getValue().billingCategory()) - .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); - } - - @Test - void preHandle_plainRouteNoAnnotations_stillShortCircuits() throws Exception { - // Guard against the path rule being too broad: a non-AI-tools route with no annotations - // must - // still short-circuit (BYPASSED path), unaffected by the AI-tools recognition. - authenticateWithUser(makeUser(7L, 42L)); - - MockMultipartHttpServletRequest req = newMultipart(); // URI = /api/v1/security/test-tool - req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); - - boolean cont = - interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); - - assertThat(cont).isTrue(); - verify(chargeService, never()).openProcess(any(), anyList()); - } - // --- helpers -------------------------------------------------------------------------------- private MockMultipartHttpServletRequest newMultipart() { @@ -739,78 +459,11 @@ class PaygChargeInterceptorTest { } } - private static HandlerMethod handlerMethodForAutomation() { - try { - Method m = FakeController.class.getDeclaredMethod("handleAutomation"); - return new HandlerMethod(new FakeController(), m); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - private static HandlerMethod handlerMethodForAi() { - try { - Method m = FakeController.class.getDeclaredMethod("handleAi"); - return new HandlerMethod(new FakeController(), m); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - private static HandlerMethod handlerMethodForAiNoAutoJob() { - try { - Method m = FakeController.class.getDeclaredMethod("handleAiNoAutoJob"); - return new HandlerMethod(new FakeController(), m); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - private static HandlerMethod handlerMethodForClassLevelAi() { - try { - Method m = ClassLevelAiController.class.getDeclaredMethod("classLevelAi"); - return new HandlerMethod(new ClassLevelAiController(), m); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - private void authenticateWithApiKey(User user) { - stirling.software.proprietary.security.model.ApiKeyAuthenticationToken token = - new stirling.software.proprietary.security.model.ApiKeyAuthenticationToken( - user, "test-api-key", List.of(new SimpleGrantedAuthority("ROLE_API"))); - SecurityContextHolder.getContext().setAuthentication(token); - } - static class FakeController { @AutoJobPostMapping(value = "/x", resourceWeight = 1) public void handleAuto() {} public void handlePlain() {} - - @AutoJobPostMapping(value = "/auto", resourceWeight = 1) - @stirling.software.saas.payg.cap.RequiresFeature( - stirling.software.saas.payg.model.FeatureGate.AUTOMATION) - public void handleAutomation() {} - - @AutoJobPostMapping(value = "/ai", resourceWeight = 1) - @stirling.software.saas.payg.cap.RequiresFeature( - stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) - public void handleAi() {} - - /** - * AI-controller shape: @RequiresFeature without @AutoJobPostMapping (JSON body / proxy). - */ - @stirling.software.saas.payg.cap.RequiresFeature( - stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) - public void handleAiNoAutoJob() {} - } - - /** Mirrors AiCreateController layout: @RequiresFeature on the class, plain methods. */ - @stirling.software.saas.payg.cap.RequiresFeature( - stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) - static class ClassLevelAiController { - public void classLevelAi() {} } /** Placeholder so AutoCloseable resources flow in some helper methods. */ diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java deleted file mode 100644 index b766fec4a..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package stirling.software.saas.payg.filter; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; - -/** - * Locks the interceptor ordering that makes "a blocked request is never charged" hold structurally: - * the {@link stirling.software.saas.payg.entitlement.EntitlementGuard} must run BEFORE the {@link - * PaygChargeInterceptor}. Spring skips a later interceptor's {@code preHandle} once an earlier one - * returns {@code false}, so guard-first means a refused (402) request never reaches {@code - * openProcess}. If these constants are ever reordered the wrong way, refused requests would start - * billing again — this test fails first. - */ -class PaygWebMvcConfigTest { - - @Test - void entitlementGuardRunsBeforeChargeInterceptor() { - assertThat(PaygWebMvcConfig.ENTITLEMENT_GUARD_ORDER) - .isLessThan(PaygWebMvcConfig.INTERCEPTOR_ORDER); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java index 6f921a6fd..e526bf7ca 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java @@ -1,72 +1,35 @@ package stirling.software.saas.payg.job; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.List; -import java.util.UUID; - import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import stirling.software.saas.payg.charge.JobChargeService; - /** - * Smoke test for the scheduler wiring. Confirms the scheduler closes each stale job through {@link - * JobChargeService#close} (so the Stripe meter afterCommit hook fires per job), isolates per-job - * failures, and tolerates an empty stale set without erroring. The close/meter logic itself is - * exercised in {@code JobChargeServiceTest}. + * Smoke test for the scheduler wiring. The interesting close logic is exercised in {@link + * JobServiceTest#closeStale_closesAllStaleJobs}; here we just confirm the scheduler bean delegates + * to {@code JobService.closeStale} and tolerates an empty result without erroring. */ class StaleJobCloserTest { - private static ProcessingJob job(UUID id) { - ProcessingJob j = new ProcessingJob(); - j.setId(id); - return j; + @Test + void closeStale_invokesJobService() { + JobService jobService = Mockito.mock(JobService.class); + when(jobService.closeStale()).thenReturn(3); + + new StaleJobCloser(jobService).closeStale(); + + verify(jobService).closeStale(); } @Test - void closeStale_closesEachStaleJobThroughChargeService() { + void closeStale_zeroClosedDoesNotThrow() { JobService jobService = Mockito.mock(JobService.class); - JobChargeService chargeService = Mockito.mock(JobChargeService.class); - UUID a = UUID.randomUUID(); - UUID b = UUID.randomUUID(); - when(jobService.findStale()).thenReturn(List.of(job(a), job(b))); + when(jobService.closeStale()).thenReturn(0); - new StaleJobCloser(jobService, chargeService).closeStale(); + new StaleJobCloser(jobService).closeStale(); - // Routed through the charge service (meter hook), NOT the bulk closeStale flip. - verify(chargeService).close(a); - verify(chargeService).close(b); - verify(jobService, never()).closeStale(); - } - - @Test - void closeStale_oneJobFailing_doesNotStrandTheRest() { - JobService jobService = Mockito.mock(JobService.class); - JobChargeService chargeService = Mockito.mock(JobChargeService.class); - UUID bad = UUID.randomUUID(); - UUID good = UUID.randomUUID(); - when(jobService.findStale()).thenReturn(List.of(job(bad), job(good))); - when(chargeService.close(bad)).thenThrow(new RuntimeException("boom")); - - // Must not propagate — the sweep continues to the next job. - new StaleJobCloser(jobService, chargeService).closeStale(); - - verify(chargeService).close(bad); - verify(chargeService).close(good); - } - - @Test - void closeStale_emptyStaleSet_doesNotTouchChargeService() { - JobService jobService = Mockito.mock(JobService.class); - JobChargeService chargeService = Mockito.mock(JobChargeService.class); - when(jobService.findStale()).thenReturn(List.of()); - - new StaleJobCloser(jobService, chargeService).closeStale(); - - verify(chargeService, never()).close(any()); + verify(jobService).closeStale(); } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java deleted file mode 100644 index f9ab02c3d..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package stirling.software.saas.payg.meter; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -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.Duration; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.data.domain.Pageable; - -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.saas.payg.policy.PaygTeamExtensions; -import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; -import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; - -/** - * Unit tests for {@link PaygMeterReconcileScheduler}: retries unposted events for still-subscribed - * teams under the same idempotency key, skips teams that have since unsubscribed, and no-ops when - * disabled. - */ -class PaygMeterReconcileSchedulerTest { - - private PaygMeterEventLogRepository logRepo; - private PaygTeamExtensionsRepository teamExtRepo; - private PaygMeterReportingService meterReportingService; - private MeterRegistry meterRegistry; - - @BeforeEach - void setUp() { - logRepo = Mockito.mock(PaygMeterEventLogRepository.class); - teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class); - meterReportingService = Mockito.mock(PaygMeterReportingService.class); - meterRegistry = new SimpleMeterRegistry(); - when(logRepo.countStuck(any())).thenReturn(0L); - } - - private PaygMeterReconcileScheduler scheduler(boolean enabled) { - return new PaygMeterReconcileScheduler( - logRepo, - teamExtRepo, - meterReportingService, - enabled, - Duration.ofMinutes(5), - 100, - meterRegistry); - } - - private static PaygMeterEventLog row(Long teamId, UUID jobId, String key, int units) { - PaygMeterEventLog e = new PaygMeterEventLog(); - e.setTeamId(teamId); - e.setJobId(jobId); - e.setIdempotencyKey(key); - e.setUnits(units); - return e; - } - - private static PaygTeamExtensions ext(Long teamId, String customerId, String subscriptionId) { - PaygTeamExtensions ext = new PaygTeamExtensions(); - ext.setTeamId(teamId); - ext.setStripeCustomerId(customerId); - ext.setPaygSubscriptionId(subscriptionId); - return ext; - } - - @Test - void reconcile_subscribedTeam_reMetersUnderSameKey() { - UUID jobId = UUID.randomUUID(); - when(logRepo.findRetryable(any(), any(), any(Pageable.class))) - .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); - when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", "sub_live"))); - - scheduler(true).reconcile(); - - verify(meterReportingService) - .recordUsage(100L, "cus_live", 4, null, "process:" + jobId + ":close", jobId); - } - - @Test - void reconcile_teamUnsubscribedSince_isSkipped() { - UUID jobId = UUID.randomUUID(); - when(logRepo.findRetryable(any(), any(), any(Pageable.class))) - .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); - // Customer still present but no live subscription → no longer billable. - when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", null))); - - scheduler(true).reconcile(); - - verify(meterReportingService, never()) - .recordUsage(any(), any(), anyInt(), any(), any(), any()); - } - - @Test - void reconcile_missingTeamExtensions_isSkipped() { - UUID jobId = UUID.randomUUID(); - when(logRepo.findRetryable(any(), any(), any(Pageable.class))) - .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); - when(teamExtRepo.findAllById(any())).thenReturn(List.of()); - - scheduler(true).reconcile(); - - verify(meterReportingService, never()) - .recordUsage(any(), any(), anyInt(), any(), any(), any()); - } - - @Test - void reconcile_disabled_isNoOp() { - scheduler(false).reconcile(); - - verifyNoInteractions(logRepo, teamExtRepo, meterReportingService); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java deleted file mode 100644 index 7ee240827..000000000 --- a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java +++ /dev/null @@ -1,245 +0,0 @@ -package stirling.software.saas.payg.meter; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -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.when; - -import java.net.ConnectException; -import java.util.Map; -import java.util.UUID; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; - -import stirling.software.saas.payg.model.BillingCategory; -import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; - -/** - * Covers the contract documented on {@link PaygMeterReportingService#recordUsage}: never throws, - * skips when endpoint is blank, counts non-2xx and exceptions on {@code payg.meter.errors}, and - * wraps every POST in a durable {@code payg_meter_event_log} row (pending → posted / failed). - */ -class PaygMeterReportingServiceTest { - - private static final String ENDPOINT = - "https://example.supabase.co/functions/v1/meter-payg-units"; - private static final String TOKEN = "test-service-role-token"; - private static final UUID JOB = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); - - private RestTemplate restTemplate; - private PaygMeterEventLogRepository eventLogRepository; - private MeterRegistry meterRegistry; - private Counter errorsCounter; - - @BeforeEach - void setUp() { - restTemplate = Mockito.mock(RestTemplate.class); - eventLogRepository = Mockito.mock(PaygMeterEventLogRepository.class); - meterRegistry = new SimpleMeterRegistry(); - errorsCounter = meterRegistry.counter("payg.meter.errors"); - } - - private PaygMeterReportingService newService(String endpoint, String token) { - return new PaygMeterReportingService( - endpoint, token, restTemplate, eventLogRepository, meterRegistry); - } - - @Test - void recordUsage_happyPath_postsBodyLogsPendingThenPosted() { - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK)); - - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job1:close", JOB); - - @SuppressWarnings("unchecked") - ArgumentCaptor>> entityCaptor = - ArgumentCaptor.forClass(HttpEntity.class); - verify(restTemplate) - .exchange( - eq(ENDPOINT), - eq(HttpMethod.POST), - entityCaptor.capture(), - eq(String.class)); - - HttpEntity> sent = entityCaptor.getValue(); - Map body = sent.getBody(); - assertThat(body).isNotNull(); - // JSON number — the edge fn type-checks team_id and ignores strings. - assertThat(body.get("team_id")).isEqualTo(100L); - assertThat(body.get("stripe_customer_id")).isEqualTo("cus_abc"); - assertThat(body.get("units")).isEqualTo(5); - assertThat(body.get("idempotency_key")).isEqualTo("process:job1:close"); - assertThat(body.get("metadata")).isEqualTo(Map.of("category", "API")); - - HttpHeaders headers = sent.getHeaders(); - assertThat(headers.getFirst("Authorization")).isEqualTo("Bearer " + TOKEN); - assertThat(headers.getContentType()).isNotNull(); - assertThat(headers.getContentType().toString()).startsWith("application/json"); - - // Durable audit: pending row written before the POST, stamped posted after success. - verify(eventLogRepository).insertPending(100L, JOB, "process:job1:close", 5); - verify(eventLogRepository).markPosted("process:job1:close"); - verify(eventLogRepository, never()).markFailed(any(), any(), any()); - assertThat(errorsCounter.count()).isZero(); - } - - @Test - void recordUsage_5xxResponse_marksFailedIncrementsErrorCounterAndDoesNotThrow() { - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenReturn(new ResponseEntity<>("oops", HttpStatus.INTERNAL_SERVER_ERROR)); - - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - assertThatCode( - () -> - service.recordUsage( - 100L, - "cus_abc", - 3, - BillingCategory.AUTOMATION, - "process:job2:close", - JOB)) - .doesNotThrowAnyException(); - - verify(eventLogRepository).insertPending(100L, JOB, "process:job2:close", 3); - verify(eventLogRepository).markFailed(eq("process:job2:close"), eq("500"), any()); - verify(eventLogRepository, never()).markPosted(any()); - assertThat(errorsCounter.count()).isEqualTo(1.0); - } - - @Test - void recordUsage_connectionRefused_marksFailedIncrementsErrorCounterAndDoesNotThrow() { - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenThrow(new ResourceAccessException("connect refused", new ConnectException())); - - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - assertThatCode( - () -> - service.recordUsage( - 100L, - "cus_abc", - 7, - BillingCategory.AI, - "process:job3:close", - JOB)) - .doesNotThrowAnyException(); - - verify(eventLogRepository).insertPending(100L, JOB, "process:job3:close", 7); - verify(eventLogRepository).markFailed(eq("process:job3:close"), eq("exception"), any()); - assertThat(errorsCounter.count()).isEqualTo(1.0); - } - - @Test - void recordUsage_runtimeException_swallowed() { - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenThrow(new RuntimeException("boom")); - - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - assertThatCode( - () -> - service.recordUsage( - 100L, - "cus_abc", - 7, - BillingCategory.AI, - "process:job4:close", - JOB)) - .doesNotThrowAnyException(); - - verify(eventLogRepository).markFailed(eq("process:job4:close"), eq("exception"), any()); - assertThat(errorsCounter.count()).isEqualTo(1.0); - } - - @Test - void recordUsage_logPendingFailure_stillPostsAndDoesNotThrow() { - // A DB hiccup writing the audit row must not stop us metering the customer. - Mockito.doThrow(new RuntimeException("db down")) - .when(eventLogRepository) - .insertPending(any(), any(), any(), anyInt()); - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenReturn(new ResponseEntity<>("{}", HttpStatus.OK)); - - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - assertThatCode( - () -> - service.recordUsage( - 100L, - "cus_abc", - 2, - BillingCategory.API, - "process:job9:close", - JOB)) - .doesNotThrowAnyException(); - - verify(restTemplate).exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)); - } - - @Test - void recordUsage_blankEndpoint_noopsAndDoesNotCallRestTemplateOrLog() { - PaygMeterReportingService service = newService("", TOKEN); - service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job5:close", JOB); - - verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); - verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); - assertThat(errorsCounter.count()).isZero(); - } - - @Test - void recordUsage_nullEndpoint_noopsAndDoesNotCallRestTemplate() { - PaygMeterReportingService service = newService(null, TOKEN); - service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job6:close", JOB); - - verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); - verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); - assertThat(errorsCounter.count()).isZero(); - } - - @Test - void recordUsage_zeroUnits_noopsAndDoesNotCallRestTemplateOrLog() { - PaygMeterReportingService service = newService(ENDPOINT, TOKEN); - service.recordUsage(100L, "cus_abc", 0, BillingCategory.API, "process:job7:close", JOB); - - verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); - verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); - assertThat(errorsCounter.count()).isZero(); - } - - @Test - void recordUsage_blankServiceRoleToken_postsWithoutAuthorizationHeader() { - when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) - .thenReturn(new ResponseEntity<>("{}", HttpStatus.OK)); - - PaygMeterReportingService service = newService(ENDPOINT, ""); - service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job8:close", JOB); - - @SuppressWarnings("unchecked") - ArgumentCaptor>> entityCaptor = - ArgumentCaptor.forClass(HttpEntity.class); - verify(restTemplate, times(1)) - .exchange( - eq(ENDPOINT), - eq(HttpMethod.POST), - entityCaptor.capture(), - eq(String.class)); - assertThat(entityCaptor.getValue().getHeaders().getFirst("Authorization")).isNull(); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java index f1a24d035..bffc17bd9 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -107,16 +107,6 @@ class PaygEntitiesSmokeTest { assertThat(entry.getAmountUnits()).isEqualTo(-4); } - @Test - void walletLedgerEntry_billingCategoryRoundTrips() { - WalletLedgerEntry entry = new WalletLedgerEntry(); - // Default (unset) is null — captured by both the legacy debit path and pre-V16 rows. - assertThat(entry.getBillingCategory()).isNull(); - - entry.setBillingCategory(BillingCategory.AUTOMATION); - assertThat(entry.getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION); - } - @Test void walletPolicy_carriesSensibleDefaults() { WalletPolicy policy = new WalletPolicy(); @@ -158,29 +148,4 @@ class PaygEntitiesSmokeTest { assertThat(row.getDiffPct()).isNegative(); } - - @Test - void paygShadowCharge_billingCategoryAndJobSourceRoundTrip() { - PaygShadowCharge row = new PaygShadowCharge(); - assertThat(row.getBillingCategory()).isNull(); - assertThat(row.getJobSource()).isNull(); - - row.setBillingCategory(BillingCategory.AI); - row.setJobSource(JobSource.API); - - assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.AI); - assertThat(row.getJobSource()).isEqualTo(JobSource.API); - } - - @Test - void billingCategory_listingOrderIsStable() { - // No downstream relies on ordinal() today, but the comment in the enum claims BYPASSED is - // declared first as the default sentinel — guard against an accidental reorder. - assertThat(BillingCategory.values()) - .containsExactly( - BillingCategory.BYPASSED, - BillingCategory.API, - BillingCategory.AI, - BillingCategory.AUTOMATION); - } } diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java index 49779fce8..5c21b88a2 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java @@ -9,25 +9,17 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; -import stirling.software.proprietary.security.model.User; import stirling.software.saas.security.SupabaseSecurityConfig.SupabaseTokenValidator; /** Unit tests for the JWT-claim → Spring authorities mapping. */ class SupabaseSecurityConfigTest { - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - @Test void anonymousJwtGetsLimitedApiUserRole() { Jwt jwt = jwtWith(true, null, null, null, List.of()); @@ -83,47 +75,6 @@ class SupabaseSecurityConfigTest { .doesNotContain("ROLE_", "PERM_"); } - @Test - void carriesUserPrincipalFromContextBuiltByAuthFilter() { - Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); - User user = new User(); - SecurityContextHolder.getContext() - .setAuthentication( - new EnhancedJwtAuthenticationToken( - jwt, List.of(), "alice@example.com", jwt.getSubject(), user)); - - AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); - - assertThat(auth.getPrincipal()).isSameAs(user); - } - - @Test - void principalStaysJwtWithoutContextUser() { - Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); - - AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); - - assertThat(auth.getPrincipal()).isSameAs(jwt); - } - - @Test - void ignoresContextUserForDifferentSubject() { - Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); - Jwt other = jwtWith(false, "bob@example.com", "authenticated", null, List.of()); - SecurityContextHolder.getContext() - .setAuthentication( - new EnhancedJwtAuthenticationToken( - other, - List.of(), - "bob@example.com", - other.getSubject(), - new User())); - - AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); - - assertThat(auth.getPrincipal()).isSameAs(jwt); - } - private static Jwt jwtWith( boolean anonymous, String email, diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java deleted file mode 100644 index 70cd360d7..000000000 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package stirling.software.saas.security; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -/** SaaS policy context: team leader may edit; scoping uses the user's team. */ -@ExtendWith(MockitoExtension.class) -class TeamLeaderPolicyManagementAuthorityTest { - - @Mock private TeamSecurityExpressions teamSecurity; - - private TeamLeaderPolicyManagementAuthority authority() { - return new TeamLeaderPolicyManagementAuthority(teamSecurity); - } - - @Test - void teamLeaderMayEditPolicies() { - when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true); - assertTrue(authority().canEditPolicies()); - } - - @Test - void nonLeaderMayNot() { - when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false); - assertFalse(authority().canEditPolicies()); - } - - @Test - void currentUserTeamIdDelegatesToTeamSecurity() { - when(teamSecurity.currentUserTeamId()).thenReturn(9L); - assertEquals(9L, authority().currentUserTeamId()); - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java deleted file mode 100644 index 23226e446..000000000 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package stirling.software.saas.security; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.AfterEach; -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.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; - -/** - * {@link TeamSecurityExpressions#isCurrentUserTeamLeader()} — used to gate policy editing on SaaS. - */ -@ExtendWith(MockitoExtension.class) -class TeamSecurityExpressionsTest { - - @Mock private TeamMembershipRepository membershipRepository; - @Mock private UserService userService; - - private static final long TEAM_ID = 2L; - private static final long USER_ID = 1L; - - private TeamSecurityExpressions expressions() { - return new TeamSecurityExpressions(membershipRepository, userService); - } - - @AfterEach - void clearContext() { - SecurityContextHolder.clearContext(); - } - - private void authenticateAsUserWithTeam(boolean hasTeam) { - User user = new User(); - user.setId(USER_ID); - if (hasTeam) { - Team team = new Team(); - team.setId(TEAM_ID); - user.setTeam(team); - } - // API-key auth path: the principal is the User entity itself. - SecurityContextHolder.getContext() - .setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of())); - } - - private TeamMembership membershipWithRole(TeamRole role) { - TeamMembership membership = new TeamMembership(); - membership.setRole(role); - return membership; - } - - @Test - void leaderOfOwnTeamIsLeader() { - authenticateAsUserWithTeam(true); - when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) - .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); - assertTrue(expressions().isCurrentUserTeamLeader()); - } - - @Test - void regularMemberIsNotLeader() { - authenticateAsUserWithTeam(true); - when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) - .thenReturn(Optional.of(membershipWithRole(TeamRole.MEMBER))); - assertFalse(expressions().isCurrentUserTeamLeader()); - } - - @Test - void noMembershipIsNotLeader() { - authenticateAsUserWithTeam(true); - when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) - .thenReturn(Optional.empty()); - assertFalse(expressions().isCurrentUserTeamLeader()); - } - - @Test - void userWithoutTeamIsNotLeader() { - authenticateAsUserWithTeam(false); - assertFalse(expressions().isCurrentUserTeamLeader()); - } - - @Test - void currentUserTeamIdReturnsTheUsersTeam() { - authenticateAsUserWithTeam(true); - assertEquals(TEAM_ID, expressions().currentUserTeamId()); - } - - @Test - void currentUserTeamIdIsNullWithoutTeam() { - authenticateAsUserWithTeam(false); - assertNull(expressions().currentUserTeamId()); - } - - @Test - void unauthenticatedIsNotLeader() { - // No authentication set on the context. - lenient() - .when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) - .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); - assertFalse(expressions().isCurrentUserTeamLeader()); - } -} diff --git a/build.gradle b/build.gradle index d67485dff..14df27ee5 100644 --- a/build.gradle +++ b/build.gradle @@ -185,10 +185,9 @@ subprojects { allowInsecureProtocol = true } } - // Maven Central first; mirrors below are fallbacks for niche artifacts. - mavenCentral() maven { url = "https://build.shibboleth.net/maven/releases" } maven { url = "https://repository.jboss.org/" } + mavenCentral() } configurations.configureEach { @@ -584,8 +583,8 @@ repositories { allowInsecureProtocol = true } } - mavenCentral() maven { url = "https://build.shibboleth.net/maven/releases" } + mavenCentral() } dependencies { diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile deleted file mode 100644 index 4cc63ddda..000000000 --- a/docker/backend/Dockerfile +++ /dev/null @@ -1,123 +0,0 @@ -# Stirling-PDF backend-only image — JAR built with -PbuildWithFrontend=false, UI ships separately. - -ARG BASE_VERSION=1.0.2 -ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} - -# Stage 1: Build the Java application (backend only, no frontend) -FROM gradle:9.3.1-jdk25@sha256:85aec999629f4774a383cb792da4b598bdf5a7e69c4b9570bb70c0f919179183 AS app-build - -# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead -ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" - -WORKDIR /app - -COPY build.gradle settings.gradle gradlew ./ -COPY gradle/ gradle/ -COPY app/core/build.gradle app/core/ -COPY app/common/build.gradle app/common/ -COPY app/proprietary/build.gradle app/proprietary/ - -# Use system gradle instead of gradlew to avoid SSL issues downloading gradle distribution on emulated arm64 -RUN gradle dependencies --no-daemon || true - -COPY . . - -ARG PROTOTYPES_BUILD=false -ARG STIRLING_FLAVOR=proprietary -ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} - -# buildWithFrontend=false → backend-only JAR with API landing page. -RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \ - gradle clean build \ - -PbuildWithFrontend=false \ - -PprototypesMode=${PROTOTYPES_BUILD} \ - -x spotlessApply -x spotlessCheck -x test -x sonarqube \ - --no-daemon - -# Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract -WORKDIR /tmp -COPY --from=app-build /app/app/core/build/libs/*.jar app.jar -RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers - - -# Stage 3: Final runtime image on top of pre-built base -FROM ${BASE_IMAGE} - -ARG VERSION_TAG - -WORKDIR /app - -# Application layers -COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/ - -COPY --link --from=app-build --chown=1000:1000 \ - /app/build/libs/restart-helper.jar /restart-helper.jar -COPY --link --chown=1000:1000 scripts/ /scripts/ - -# Fonts go to system dir, root ownership is correct (world-readable) -COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ - -# Permissions and configuration -RUN set -eux; \ - chmod +x /scripts/*; \ - ln -s /logs /app/logs; \ - ln -s /configs /app/configs; \ - ln -s /customFiles /app/customFiles; \ - ln -s /pipeline /app/pipeline; \ - ln -s /storage /app/storage; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ - chown stirlingpdfuser:stirlingpdfgroup /app; \ - chmod 750 /tmp/stirling-pdf; \ - chmod 750 /tmp/stirling-pdf/heap_dumps; \ - fc-cache -f - -# Version file for scripts (init-without-ocr.sh reads /etc/stirling_version). -RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version - -# Environment variables -ENV VERSION_TAG=$VERSION_TAG \ - STIRLING_AOT_ENABLE="false" \ - STIRLING_JVM_PROFILE="balanced" \ - _JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \ - _JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \ - JAVA_CUSTOM_OPTS="" \ - HOME=/home/stirlingpdfuser \ - PUID=1000 \ - PGID=1000 \ - UMASK=022 \ - STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ - TMPDIR=/tmp/stirling-pdf \ - TEMP=/tmp/stirling-pdf \ - TMP=/tmp/stirling-pdf \ - DBUS_SESSION_BUS_ADDRESS=/dev/null \ - SAL_TMP=/tmp/stirling-pdf/libre - -# Metadata labels -LABEL org.opencontainers.image.title="Stirling-PDF Backend" \ - org.opencontainers.image.description="Backend-only version (no embedded UI) with Calibre, LibreOffice, Tesseract, OCRmyPDF" \ - org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ - org.opencontainers.image.licenses="MIT" \ - org.opencontainers.image.vendor="Stirling-Tools" \ - org.opencontainers.image.url="https://www.stirlingpdf.com" \ - org.opencontainers.image.documentation="https://docs.stirlingpdf.com" \ - maintainer="Stirling-Tools" \ - org.opencontainers.image.authors="Stirling-Tools" \ - org.opencontainers.image.version="${VERSION_TAG}" \ - org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot" - -EXPOSE 8080/tcp -STOPSIGNAL SIGTERM - -HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \ - CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1 - -ENTRYPOINT ["tini", "--", "/scripts/init.sh"] -CMD [] diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 67e35f82c..e92f19ddc 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,44 +1,38 @@ -# check=skip=SecretsUsedInArgOrEnv -# Supabase publishable ARG is client-safe by design, not a real secret. - -# Stage 1: build +# Frontend Dockerfile - React/Vite application FROM node:25-alpine@sha256:e80397b81fa93888b5f855e8bef37d9b18d3c5eb38b8731fc23d6d878647340f AS build WORKDIR /app +# Copy package files COPY frontend/package.json frontend/package-lock.json ./ + +# Install dependencies RUN npm ci +# Copy source code COPY frontend . -# Generate material-symbols icon subset (normally done by task prepare:icons). -RUN node editor/scripts/generate-icons.js +# Build the application (vite root is editor/, output lands in editor/dist/) +RUN npx vite build editor -# Defaults match prior behaviour. Supabase values are client-publishable build args. -ARG STIRLING_FLAVOR=proprietary -ARG VITE_BUILD_MODE=production -ARG VITE_SUPABASE_URL="" -# pragma: allowlist secret -ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" - -# Build vite from editor/, output lands in editor/dist/. -RUN set -eu; \ - export STIRLING_FLAVOR="${STIRLING_FLAVOR}"; \ - if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ - if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ - npx vite build editor --mode "${VITE_BUILD_MODE}" - -# Stage 2: nginx +# Production stage FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55 +# Copy built files from build stage COPY --from=build /app/editor/dist /usr/share/nginx/html + +# Copy nginx configuration and entrypoint COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf COPY docker/frontend/entrypoint.sh /entrypoint.sh +# Make entrypoint executable RUN chmod +x /entrypoint.sh +# Expose port 80 (standard HTTP port) EXPOSE 80 +# Environment variables for flexibility ENV VITE_API_BASE_URL=http://backend:8080 -ENTRYPOINT ["/entrypoint.sh"] +# Use custom entrypoint +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/engine/.env b/engine/.env index a99abf0cc..e334a9bf9 100644 --- a/engine/.env +++ b/engine/.env @@ -14,30 +14,19 @@ STIRLING_FAST_MODEL=anthropic:claude-haiku-4-5 STIRLING_SMART_MODEL_MAX_TOKENS=8192 STIRLING_FAST_MODEL_MAX_TOKENS=2048 -# Process-wide cap on concurrent model API calls, shared by both model tiers. -# Per-request fan-outs (chunked reasoner workers, contradiction detection) are -# bounded per request; this bounds their product across concurrent requests. -STIRLING_MODEL_MAX_CONCURRENCY=32 +# RAG Configuration — retrieval-augmented generation is always on. +# Embedding provider credentials are handled natively (e.g. VOYAGE_API_KEY for VoyageAI). +STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 -# Document store: the one database holding vector chunks, ordered page text, -# and ACL rows. Backend is "sqlite" (embedded sqlite-vec) or "pgvector" -# (external Postgres). -STIRLING_DOCUMENTS_BACKEND=sqlite +# Vector store backend: "sqlite" (embedded) or "pgvector" (external Postgres). +STIRLING_RAG_BACKEND=sqlite # Path to the sqlite-vec database file (used when backend=sqlite). -STIRLING_DOCUMENTS_SQLITE_PATH=data/rag.db +STIRLING_RAG_STORE_PATH=data/rag.db # Postgres DSN for pgvector (used when backend=pgvector). Leave empty when backend=sqlite. # Example: postgresql://user:password@host:5432/dbname -STIRLING_DOCUMENTS_PGVECTOR_DSN= - -# Connection pool bounds for the pgvector backend. -STIRLING_DOCUMENTS_PGVECTOR_POOL_MIN_SIZE=1 -STIRLING_DOCUMENTS_PGVECTOR_POOL_MAX_SIZE=10 - -# RAG Configuration - retrieval-augmented generation is always on. -# Embedding provider credentials are handled natively (e.g. VOYAGE_API_KEY for VoyageAI). -STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 +STIRLING_RAG_PGVECTOR_DSN= STIRLING_RAG_CHUNK_SIZE=512 STIRLING_RAG_CHUNK_OVERLAP=64 @@ -68,11 +57,6 @@ STIRLING_CHUNKED_REASONER_NOTES_CHAR_BUDGET=250000 STIRLING_MAX_PAGES=200 STIRLING_MAX_CHARACTERS=200000 -# Reject API requests that lack an X-User-Id header. Self-hosted deployments -# with security disabled have no user identity, so this is off by default. -# Multi-tenant (SaaS) deployments must set it to true. -STIRLING_REQUIRE_USER_ID=false - # PostHog analytics. Set STIRLING_POSTHOG_ENABLED=true and provide an API key to enable. STIRLING_POSTHOG_ENABLED=false STIRLING_POSTHOG_API_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz diff --git a/engine/Dockerfile b/engine/Dockerfile index 375983142..c9c8d2239 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -10,27 +10,18 @@ RUN apt-get update \ && rm /tmp/task.deb \ && rm -rf /var/lib/apt/lists/* -# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`. -WORKDIR /app/engine +WORKDIR /app -COPY pyproject.toml uv.lock .env ./ +COPY pyproject.toml uv.lock Taskfile.yml .env ./ +COPY .taskfiles/ ./.taskfiles/ COPY scripts/ ./scripts/ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev COPY src/ ./src/ -WORKDIR /app -COPY Taskfile.yml ./ -COPY .taskfiles/ ./.taskfiles/ - -ENV PATH="/app/engine/.venv/bin:$PATH" +ENV PATH="/app/.venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 -ENV STIRLING_ENGINE_WORKERS=4 -# Container runs on a fixed port; skip the host-only free-port probe (its script -# is not shipped in the image). engine:run honours these. -ENV ENGINE_PORT_PROBE=false -ENV STIRLING_ENGINE_PORT=5001 EXPOSE 5001 diff --git a/engine/pyproject.toml b/engine/pyproject.toml index fa972c849..2e26756c7 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "fastapi>=0.116.0", "jinja2>=3.1.0", "pgvector>=0.3.6", - "psycopg[binary,pool]>=3.2", + "psycopg[binary]>=3.2", "pydantic>=2.0.0", "pydantic-ai>=1.67.0", "pydantic-ai-slim[voyageai]>=1.67.0", diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 7e6272c28..699cede06 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -18,7 +18,6 @@ from stirling.agents import ( ) from stirling.agents.ledger import MathAuditorAgent from stirling.agents.pdf_comment import PdfCommentAgent -from stirling.api.dependencies import enforce_required_user_id from stirling.api.engine_auth import EngineSharedSecretMiddleware from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( @@ -119,18 +118,15 @@ async def lifespan(fast_api: FastAPI): app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0") app.add_middleware(UserIdMiddleware) app.add_middleware(EngineSharedSecretMiddleware) -# Every router gets the same configurable identity gate; /health stays open -# for liveness probes. See enforce_required_user_id for the policy. -_user_gate = [Depends(enforce_required_user_id)] -app.include_router(orchestrator_router, dependencies=_user_gate) -app.include_router(pdf_edit_router, dependencies=_user_gate) -app.include_router(pdf_question_router, dependencies=_user_gate) -app.include_router(agent_draft_router, dependencies=_user_gate) -app.include_router(execution_router, dependencies=_user_gate) -app.include_router(document_router, dependencies=_user_gate) -app.include_router(ledger_router, dependencies=_user_gate) -app.include_router(pdf_comments_router, dependencies=_user_gate) -app.include_router(agent_capabilities_router, dependencies=_user_gate) +app.include_router(orchestrator_router) +app.include_router(pdf_edit_router) +app.include_router(pdf_question_router) +app.include_router(agent_draft_router) +app.include_router(execution_router) +app.include_router(document_router) +app.include_router(ledger_router) +app.include_router(pdf_comments_router) +app.include_router(agent_capabilities_router) @app.get("/health", response_model=HealthResponse) diff --git a/engine/src/stirling/api/dependencies.py b/engine/src/stirling/api/dependencies.py index 780e97c9d..cae20a8ae 100644 --- a/engine/src/stirling/api/dependencies.py +++ b/engine/src/stirling/api/dependencies.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import Annotated - -from fastapi import Depends, HTTPException, Request, status +from fastapi import HTTPException, Request, status from stirling.agents import ( ExecutionPlanningAgent, @@ -13,7 +11,6 @@ from stirling.agents import ( ) from stirling.agents.ledger import MathAuditorAgent from stirling.agents.pdf_comment import PdfCommentAgent -from stirling.config import AppSettings, load_settings from stirling.documents import DocumentService from stirling.models import UserId from stirling.services import AppRuntime, current_user_id @@ -70,16 +67,3 @@ def require_user_id() -> UserId: detail="X-User-Id header is required", ) return user_id - - -def enforce_required_user_id( - settings: Annotated[AppSettings, Depends(load_settings)], -) -> None: - """Router-level boundary gate, applied uniformly to every router.""" - if not settings.require_user_id: - return - if current_user_id.get() is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="X-User-Id header is required", - ) diff --git a/engine/src/stirling/config/__init__.py b/engine/src/stirling/config/__init__.py index f038d02e5..c5044a214 100644 --- a/engine/src/stirling/config/__init__.py +++ b/engine/src/stirling/config/__init__.py @@ -1,10 +1,10 @@ """Configuration models and loaders for the Stirling AI service.""" -from .settings import ENGINE_ROOT, AppSettings, DocumentsBackend, load_settings +from .settings import ENGINE_ROOT, AppSettings, RagBackend, load_settings __all__ = [ "ENGINE_ROOT", "AppSettings", - "DocumentsBackend", + "RagBackend", "load_settings", ] diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index 1293cc371..4cd2bbc9e 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -15,7 +15,7 @@ ENV_FILE = ENGINE_ROOT / ".env" ENV_LOCAL_FILE = ENGINE_ROOT / ".env.local" -class DocumentsBackend(StrEnum): +class RagBackend(StrEnum): SQLITE = "sqlite" PGVECTOR = "pgvector" @@ -27,22 +27,12 @@ class AppSettings(BaseSettings): fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL") smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS") fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS") - # Process-wide ceiling on concurrent model API calls, shared by both model - # tiers. Per-request fan-outs (chunked reasoner, contradiction detection) - # carry their own per-request caps, but those multiply under concurrent - # traffic; this is the global backstop. - model_max_concurrency: int = Field(validation_alias="STIRLING_MODEL_MAX_CONCURRENCY") - # Document store: the one database holding vector chunks, ordered page - # text, and ACL rows - embedded sqlite-vec or external pgvector. - documents_backend: DocumentsBackend = Field(validation_alias="STIRLING_DOCUMENTS_BACKEND") - documents_sqlite_path: Path = Field(validation_alias="STIRLING_DOCUMENTS_SQLITE_PATH") - documents_pgvector_dsn: str = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_DSN") - documents_pgvector_pool_min_size: int = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_POOL_MIN_SIZE") - documents_pgvector_pool_max_size: int = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_POOL_MAX_SIZE") - - # RAG settings - always on. + # RAG settings — always on; the backend picks between embedded sqlite-vec and external pgvector. + rag_backend: RagBackend = Field(validation_alias="STIRLING_RAG_BACKEND") rag_embedding_model: str = Field(validation_alias="STIRLING_RAG_EMBEDDING_MODEL") + rag_store_path: Path = Field(validation_alias="STIRLING_RAG_STORE_PATH") + rag_pgvector_dsn: str = Field(validation_alias="STIRLING_RAG_PGVECTOR_DSN") rag_chunk_size: int = Field(validation_alias="STIRLING_RAG_CHUNK_SIZE") rag_chunk_overlap: int = Field(validation_alias="STIRLING_RAG_CHUNK_OVERLAP") rag_default_top_k: int = Field(validation_alias="STIRLING_RAG_TOP_K") @@ -98,12 +88,6 @@ class AppSettings(BaseSettings): max_pages: int = Field(validation_alias="STIRLING_MAX_PAGES") max_characters: int = Field(validation_alias="STIRLING_MAX_CHARACTERS") - # When true, API routes reject requests that lack an X-User-Id header at - # the boundary. Self-hosted deployments with security disabled have no - # user identity and leave this off; multi-tenant deployments turn it on so - # user-scoped work is never processed without a tenant attached. - require_user_id: bool = Field(validation_alias="STIRLING_REQUIRE_USER_ID") - log_level: str = Field(default="INFO", validation_alias="STIRLING_LOG_LEVEL") log_file: str = Field(default="", validation_alias="STIRLING_LOG_FILE") # When true, raises httpx + httpcore logger levels so every outgoing diff --git a/engine/src/stirling/documents/README.md b/engine/src/stirling/documents/README.md index 6848d8a10..e1c759044 100644 --- a/engine/src/stirling/documents/README.md +++ b/engine/src/stirling/documents/README.md @@ -54,10 +54,10 @@ everything = RagCapability(runtime.documents) Non-secret defaults live in the committed `engine/.env`: ``` -STIRLING_DOCUMENTS_BACKEND=sqlite # or "pgvector" +STIRLING_RAG_BACKEND=sqlite # or "pgvector" STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 -STIRLING_DOCUMENTS_SQLITE_PATH=data/rag.db # used when backend=sqlite -STIRLING_DOCUMENTS_PGVECTOR_DSN= # used when backend=pgvector +STIRLING_RAG_STORE_PATH=data/rag.db # used when backend=sqlite +STIRLING_RAG_PGVECTOR_DSN= # used when backend=pgvector STIRLING_RAG_CHUNK_SIZE=512 STIRLING_RAG_CHUNK_OVERLAP=64 STIRLING_RAG_TOP_K=5 @@ -76,7 +76,7 @@ VOYAGE_API_KEY=your-key and self-hosted deployments. **`pgvector`** - External PostgreSQL with the `vector` extension. Point -`STIRLING_DOCUMENTS_PGVECTOR_DSN` at your Postgres instance. +`STIRLING_RAG_PGVECTOR_DSN` at your Postgres instance. Both backends implement the same `DocumentStore` interface, so agents and the service work identically regardless of which you pick. diff --git a/engine/src/stirling/documents/pgvector_store.py b/engine/src/stirling/documents/pgvector_store.py index 0942de72e..bdaf43011 100644 --- a/engine/src/stirling/documents/pgvector_store.py +++ b/engine/src/stirling/documents/pgvector_store.py @@ -1,13 +1,11 @@ from __future__ import annotations -import asyncio import json from datetime import datetime import psycopg from pgvector import Vector from pgvector.psycopg import register_vector_async -from psycopg_pool import AsyncConnectionPool from stirling.contracts.documents import Page, PageRange from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage @@ -16,20 +14,12 @@ from stirling.models import OwnerId, PrincipalId _READ_PERMISSION = "read" -async def _register_vector(conn: psycopg.AsyncConnection) -> None: - await register_vector_async(conn) - - class PgVectorStore(DocumentStore): """PostgreSQL + pgvector backed store, scoped by owner with ACL-gated reads. Connects to an external Postgres instance (DSN provided via config) and uses the `vector` extension for similarity search. The schema is created on first use. - Queries run on a shared async connection pool. The pool is opened lazily on - first use, after the schema bootstrap, because each pooled connection's - configure hook registers the ``vector`` type, which must already exist. - Owned tables: * ``documents_meta`` - parent row, one per ``(collection, owner_id)``. @@ -42,39 +32,28 @@ class PgVectorStore(DocumentStore): Writes are owner-scoped; reads are ACL-scoped. """ - def __init__(self, dsn: str, pool_min_size: int, pool_max_size: int) -> None: + def __init__(self, dsn: str) -> None: if not dsn: - raise ValueError("pgvector backend requires a non-empty DSN (STIRLING_DOCUMENTS_PGVECTOR_DSN)") + raise ValueError("pgvector backend requires a non-empty DSN (STIRLING_RAG_PGVECTOR_DSN)") self._dsn = dsn - self._pool = AsyncConnectionPool( - dsn, - min_size=pool_min_size, - max_size=pool_max_size, - open=False, - configure=_register_vector, - check=AsyncConnectionPool.check_connection, - ) self._initialized = False - self._init_lock = asyncio.Lock() - async def _ensure_ready(self) -> None: - """Create the schema and open the pool, exactly once across concurrent callers.""" + async def _connect(self) -> psycopg.AsyncConnection: + conn = await psycopg.AsyncConnection.connect(self._dsn) + await register_vector_async(conn) + return conn + + async def _ensure_schema(self) -> None: if self._initialized: return - async with self._init_lock: - if self._initialized: - return - await self._bootstrap_schema() - await self._pool.open() - self._initialized = True - - async def _bootstrap_schema(self) -> None: - # The `vector` type must exist before the pool's configure hook - # (register_vector_async) can resolve it, and on a fresh database it doesn't - # yet. Create the extension + schema on a raw, non-pool connection that hasn't - # registered the type; _ensure_ready then opens the pool, whose configure hook - # registers the now-existing type per connection. - async with await psycopg.AsyncConnection.connect(self._dsn) as conn: + # The `vector` type must exist before register_vector_async (called inside + # _connect) can resolve it. On a fresh database it doesn't yet, so create the + # extension first on a raw connection that hasn't registered the type. + async with await psycopg.AsyncConnection.connect(self._dsn) as bootstrap: + async with bootstrap.cursor() as cur: + await cur.execute("CREATE EXTENSION IF NOT EXISTS vector") + await bootstrap.commit() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute("CREATE EXTENSION IF NOT EXISTS vector") await cur.execute( @@ -147,6 +126,7 @@ class PgVectorStore(DocumentStore): "CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)" ) await conn.commit() + self._initialized = True # ── lifecycle of the (collection, owner_id) row ──────────────────────── @@ -157,8 +137,8 @@ class PgVectorStore(DocumentStore): owner_id: OwnerId, expires_at: datetime | None, ) -> None: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute( """ @@ -173,8 +153,8 @@ class PgVectorStore(DocumentStore): await conn.commit() async def purge_owner(self, owner_id: OwnerId) -> int: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM documents_meta WHERE owner_id = %s", @@ -185,8 +165,8 @@ class PgVectorStore(DocumentStore): return deleted async def reap_expired(self) -> int: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute("DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < NOW()") deleted = cur.rowcount @@ -194,8 +174,8 @@ class PgVectorStore(DocumentStore): return deleted async def delete_collection(self, collection: str, owner_id: OwnerId) -> bool: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: # Cascade FKs handle rag_documents, document_pages, document_acl. await cur.execute( @@ -220,8 +200,8 @@ class PgVectorStore(DocumentStore): if not documents: return - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: for doc, emb in zip(documents, embeddings): await cur.execute( @@ -239,8 +219,8 @@ class PgVectorStore(DocumentStore): await conn.commit() async def add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM document_pages WHERE collection = %s AND owner_id = %s", @@ -266,8 +246,8 @@ class PgVectorStore(DocumentStore): ) -> None: if not principals: return - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.executemany( """ @@ -285,8 +265,8 @@ class PgVectorStore(DocumentStore): owner_id: OwnerId, principal: PrincipalId, ) -> None: - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM document_acl WHERE collection = %s AND owner_id = %s AND principal_id = %s", @@ -305,8 +285,8 @@ class PgVectorStore(DocumentStore): ) -> list[SearchResult]: if not principals: return [] - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) if owner_id is None: @@ -361,8 +341,8 @@ class PgVectorStore(DocumentStore): ) -> list[Page]: if not principals: return [] - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) if owner_id is None: @@ -387,8 +367,8 @@ class PgVectorStore(DocumentStore): async def has_collection(self, collection: str, principals: list[PrincipalId]) -> bool: if not principals: return False - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) return owner_id is not None @@ -396,8 +376,8 @@ class PgVectorStore(DocumentStore): async def list_collections(self, principals: list[PrincipalId]) -> list[str]: if not principals: return [] - await self._ensure_ready() - async with self._pool.connection() as conn: + await self._ensure_schema() + async with await self._connect() as conn: async with conn.cursor() as cur: await cur.execute( """ @@ -412,4 +392,5 @@ class PgVectorStore(DocumentStore): return [r[0] for r in rows] async def close(self) -> None: - await self._pool.close() + # Connections are opened and closed per call, so nothing persistent to release. + return None diff --git a/engine/src/stirling/services/runtime.py b/engine/src/stirling/services/runtime.py index 011eb8aaf..bfaa895b2 100644 --- a/engine/src/stirling/services/runtime.py +++ b/engine/src/stirling/services/runtime.py @@ -1,22 +1,16 @@ from __future__ import annotations -import asyncio import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any, assert_never +from typing import assert_never import httpx -from pydantic_ai import RunContext -from pydantic_ai.messages import ModelMessage, ModelResponse -from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model +from pydantic_ai.models import Model, infer_model from pydantic_ai.models.anthropic import AnthropicModel -from pydantic_ai.models.wrapper import WrapperModel from pydantic_ai.providers.anthropic import AnthropicProvider from pydantic_ai.settings import ModelSettings -from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend +from stirling.config import ENGINE_ROOT, AppSettings, RagBackend from stirling.documents import ( DocumentService, DocumentStore, @@ -49,37 +43,6 @@ def _build_anthropic_http_client() -> httpx.AsyncClient: ) -class ConcurrencyLimitedModel(WrapperModel): - """Caps in-flight model API calls with a semaphore shared across the process.""" - - def __init__(self, wrapped: Model, semaphore: asyncio.Semaphore) -> None: - super().__init__(wrapped) - self._semaphore = semaphore - - async def request( - self, - messages: list[ModelMessage], - model_settings: ModelSettings | None, - model_request_parameters: ModelRequestParameters, - ) -> ModelResponse: - async with self._semaphore: - return await super().request(messages, model_settings, model_request_parameters) - - @asynccontextmanager - async def request_stream( - self, - messages: list[ModelMessage], - model_settings: ModelSettings | None, - model_request_parameters: ModelRequestParameters, - run_context: RunContext[Any] | None = None, - ) -> AsyncIterator[StreamedResponse]: - async with self._semaphore: - async with super().request_stream( - messages, model_settings, model_request_parameters, run_context - ) as response_stream: - yield response_stream - - @dataclass(frozen=True) class AppRuntime: settings: AppSettings @@ -114,21 +77,17 @@ def validate_structured_output_support(model: Model, model_name: str) -> None: def _build_document_store(settings: AppSettings) -> DocumentStore: """Build the configured document store backend.""" - if settings.documents_backend == DocumentsBackend.SQLITE: - store_path = settings.documents_sqlite_path + if settings.rag_backend == RagBackend.SQLITE: + store_path = settings.rag_store_path # Treat ":memory:" as a special in-process token; otherwise resolve against the engine root. if str(store_path) != ":memory:" and not store_path.is_absolute(): store_path = ENGINE_ROOT / store_path logger.info("Document store backend=sqlite, db_path=%s", store_path) return SqliteVecStore(db_path=store_path) - if settings.documents_backend == DocumentsBackend.PGVECTOR: + if settings.rag_backend == RagBackend.PGVECTOR: logger.info("Document store backend=pgvector, dsn=") - return PgVectorStore( - dsn=settings.documents_pgvector_dsn, - pool_min_size=settings.documents_pgvector_pool_min_size, - pool_max_size=settings.documents_pgvector_pool_max_size, - ) - assert_never(settings.documents_backend) + return PgVectorStore(dsn=settings.rag_pgvector_dsn) + assert_never(settings.rag_backend) def _build_documents(settings: AppSettings) -> DocumentService: @@ -149,13 +108,10 @@ def build_runtime(settings: AppSettings) -> AppRuntime: validate_structured_output_support(fast_model, settings.fast_model_name) validate_structured_output_support(smart_model, settings.smart_model_name) - # One semaphore across both tiers: the cap protects the provider account - # and process resources, which the tiers share. - model_semaphore = asyncio.Semaphore(settings.model_max_concurrency) return AppRuntime( settings=settings, - fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore), - smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore), + fast_model=fast_model, + smart_model=smart_model, documents=_build_documents(settings), ) diff --git a/engine/tests/conftest.py b/engine/tests/conftest.py index d4c854313..1898b9129 100644 --- a/engine/tests/conftest.py +++ b/engine/tests/conftest.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from stirling.config import AppSettings, DocumentsBackend, load_settings +from stirling.config import AppSettings, RagBackend, load_settings from stirling.services import build_runtime from stirling.services.runtime import AppRuntime @@ -23,13 +23,10 @@ def build_app_settings() -> AppSettings: fast_model_name="test", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - model_max_concurrency=32, - documents_backend=DocumentsBackend.SQLITE, + rag_backend=RagBackend.SQLITE, rag_embedding_model="voyageai:voyage-4", - documents_sqlite_path=Path(":memory:"), - documents_pgvector_dsn="", - documents_pgvector_pool_min_size=1, - documents_pgvector_pool_max_size=10, + rag_store_path=Path(":memory:"), + rag_pgvector_dsn="", rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -44,7 +41,6 @@ def build_app_settings() -> AppSettings: contradiction_canonicaliser_batch_size=500, max_pages=200, max_characters=200_000, - require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/tests/pdf_comment/test_routes.py b/engine/tests/pdf_comment/test_routes.py index 0056e10fe..6fce78f25 100644 --- a/engine/tests/pdf_comment/test_routes.py +++ b/engine/tests/pdf_comment/test_routes.py @@ -16,7 +16,7 @@ from fastapi.testclient import TestClient from stirling.api import app from stirling.api.dependencies import get_pdf_comment_agent -from stirling.config import AppSettings, DocumentsBackend, load_settings +from stirling.config import AppSettings, RagBackend, load_settings from stirling.contracts.pdf_comments import ( PdfCommentInstruction, PdfCommentRequest, @@ -35,13 +35,10 @@ class StubSettingsProvider: fast_model_name="test", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - model_max_concurrency=32, - documents_backend=DocumentsBackend.SQLITE, + rag_backend=RagBackend.SQLITE, rag_embedding_model="test-embed", - documents_sqlite_path=Path(":memory:"), - documents_pgvector_dsn="", - documents_pgvector_pool_min_size=1, - documents_pgvector_pool_max_size=10, + rag_store_path=Path(":memory:"), + rag_pgvector_dsn="", rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -52,7 +49,6 @@ class StubSettingsProvider: chunked_reasoner_notes_char_budget=250_000, max_pages=100, max_characters=100_000, - require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/tests/test_model_concurrency.py b/engine/tests/test_model_concurrency.py deleted file mode 100644 index ada8b5727..000000000 --- a/engine/tests/test_model_concurrency.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest -from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart -from pydantic_ai.models import Model, ModelRequestParameters -from pydantic_ai.settings import ModelSettings - -from stirling.services.runtime import ConcurrencyLimitedModel - - -class TrackingModel(Model): - """Records the high-water mark of concurrent in-flight requests.""" - - def __init__(self) -> None: - super().__init__() - self.active = 0 - self.max_active = 0 - - @property - def model_name(self) -> str: - return "tracking" - - @property - def system(self) -> str: - return "test" - - async def request( - self, - messages: list[ModelMessage], - model_settings: ModelSettings | None, - model_request_parameters: ModelRequestParameters, - ) -> ModelResponse: - self.active += 1 - self.max_active = max(self.max_active, self.active) - # Yield twice so every gathered task gets a chance to be in flight - # together before any of them completes. - await asyncio.sleep(0) - await asyncio.sleep(0) - self.active -= 1 - return ModelResponse(parts=[TextPart(content="ok")]) - - -@pytest.mark.anyio -async def test_shared_semaphore_caps_concurrency_across_models() -> None: - inner = TrackingModel() - semaphore = asyncio.Semaphore(2) - fast = ConcurrencyLimitedModel(inner, semaphore) - smart = ConcurrencyLimitedModel(inner, semaphore) - params = ModelRequestParameters() - - await asyncio.gather( - *(fast.request([], None, params) for _ in range(5)), - *(smart.request([], None, params) for _ in range(5)), - ) - - assert inner.max_active == 2 diff --git a/engine/tests/test_stirling_api.py b/engine/tests/test_stirling_api.py index 3a6497174..4eaecf90f 100644 --- a/engine/tests/test_stirling_api.py +++ b/engine/tests/test_stirling_api.py @@ -214,26 +214,6 @@ def test_pdf_edit_route() -> None: assert response.json()["outcome"] == "cannot_do" -def test_routes_require_user_id_when_enforced() -> None: - """With STIRLING_REQUIRE_USER_ID on, an identity-less request is rejected - at the boundary before any handler runs; supplying X-User-Id is accepted. - The other route tests in this module run with the flag off and cover the - identity-less self-hosted path.""" - app.dependency_overrides[load_settings] = lambda: build_app_settings().model_copy(update={"require_user_id": True}) - try: - anonymous = client.post("/api/v1/pdf/edit", json={"userMessage": "rotate this"}) - identified = client.post( - "/api/v1/pdf/edit", - json={"userMessage": "rotate this"}, - headers={"X-User-Id": "alice"}, - ) - finally: - app.dependency_overrides[load_settings] = build_app_settings - - assert anonymous.status_code == 401 - assert identified.status_code == 200 - - def test_pdf_questions_route() -> None: response = client.post( "/api/v1/pdf/questions", diff --git a/engine/tests/test_stirling_contracts.py b/engine/tests/test_stirling_contracts.py index 7270955c2..ccc057adb 100644 --- a/engine/tests/test_stirling_contracts.py +++ b/engine/tests/test_stirling_contracts.py @@ -77,20 +77,17 @@ def test_pdf_question_answer_defaults_evidence_list() -> None: def test_app_settings_accepts_model_configuration() -> None: from pathlib import Path - from stirling.config import DocumentsBackend + from stirling.config import RagBackend settings = AppSettings( smart_model_name="claude-sonnet-4-5-20250929", fast_model_name="claude-haiku-4-5-20251001", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - model_max_concurrency=32, - documents_backend=DocumentsBackend.SQLITE, + rag_backend=RagBackend.SQLITE, rag_embedding_model="voyageai:voyage-4", - documents_sqlite_path=Path(":memory:"), - documents_pgvector_dsn="", - documents_pgvector_pool_min_size=1, - documents_pgvector_pool_max_size=10, + rag_store_path=Path(":memory:"), + rag_pgvector_dsn="", rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -101,7 +98,6 @@ def test_app_settings_accepts_model_configuration() -> None: chunked_reasoner_notes_char_budget=250_000, max_pages=200, max_characters=200_000, - require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/uv.lock b/engine/uv.lock index bac51cb3c..9e7b44918 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -608,7 +608,7 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "pgvector" }, { name = "posthog" }, - { name = "psycopg", extra = ["binary", "pool"] }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "pydantic-ai-slim", extra = ["voyageai"] }, @@ -635,7 +635,7 @@ requires-dist = [ { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "pgvector", specifier = ">=0.3.6" }, { name = "posthog", specifier = ">=3.0.0" }, - { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-ai", specifier = ">=1.67.0" }, { name = "pydantic-ai-slim", extras = ["voyageai"], specifier = ">=1.67.0" }, @@ -2167,9 +2167,6 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] -pool = [ - { name = "psycopg-pool" }, -] [[package]] name = "psycopg-binary" @@ -2200,18 +2197,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] -[[package]] -name = "psycopg-pool" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, -] - [[package]] name = "py-key-value-aio" version = "0.4.4" diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 0797a367a..7a53d8e9c 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,14 +1,12 @@ import { resolve } from "node:path"; import type { StorybookConfig } from "@storybook/react-vite"; -import tsconfigPaths from "vite-tsconfig-paths"; /** * Storybook 9 ships essentials, interactions, and docs as built-ins, so the * addon list is just the extras we want: theme switching + a11y auditing. * - * Story files live next to their components in shared/, portal/src/, and - * editor/src/ — the design system is shared by BOTH apps, so both surface - * their stories here. MDX docs pages live in portal/src/docs/. + * Story files live next to their components in shared/ and portal/src/. + * MDX docs pages live in portal/src/docs/. */ const config: StorybookConfig = { stories: [ @@ -16,7 +14,6 @@ const config: StorybookConfig = { "../portal/src/**/*.stories.@(ts|tsx)", "../shared/**/*.mdx", "../shared/**/*.stories.@(ts|tsx)", - "../editor/src/**/*.stories.@(ts|tsx)", ], addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], framework: { @@ -31,26 +28,13 @@ const config: StorybookConfig = { staticDirs: ["../portal/public"], viteFinal: async (config) => { // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so - // portal story imports resolve without needing the portal's vite config. + // story imports resolve without needing the portal's vite config. config.resolve = config.resolve ?? {}; config.resolve.alias = { ...(config.resolve.alias ?? {}), "@portal": resolve(__dirname, "../portal/src"), "@shared": resolve(__dirname, "../shared"), }; - // Editor stories import via @app/* (proprietary→core fallback), @core/* and - // @proprietary/*. Resolve them exactly the way the editor's own build does — - // through vite-tsconfig-paths against the proprietary vite tsconfig — so the - // shared Storybook can host editor components without duplicating the alias - // map here. - config.plugins = config.plugins ?? []; - config.plugins.push( - tsconfigPaths({ - projects: [ - resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"), - ], - }), - ); return config; }, }; diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index ee182fe99..2de6a435f 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -8,5 +8,8 @@ # Userback feedback widget — leave blank to disable VITE_USERBACK_TOKEN= -# Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time). +# URL subpath prefix for SaaS deployments (e.g. "app" if serving at /app/) — leave blank for root +VITE_RUN_SUBPATH= + +# Development-only auth bypass — allows unauthenticated access on localhost in dev mode VITE_DEV_BYPASS_AUTH=false diff --git a/frontend/editor/public/css/cookieconsentCustomisation.css b/frontend/editor/public/css/cookieconsentCustomisation.css index 25ec0fd52..fd1a8ff35 100644 --- a/frontend/editor/public/css/cookieconsentCustomisation.css +++ b/frontend/editor/public/css/cookieconsentCustomisation.css @@ -20,18 +20,17 @@ --cc-separator-border-color: #e0e0e0; - /* Toggle colors mirror Mantine Switch (light scheme) */ - --cc-toggle-on-bg: var(--mantine-primary-color-filled, #007bff); - --cc-toggle-off-bg: var(--mantine-color-gray-3, #dee2e6); - --cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff); - --cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff); + --cc-toggle-on-bg: #007bff; + --cc-toggle-off-bg: #667481; + --cc-toggle-on-knob-bg: #ffffff; + --cc-toggle-off-knob-bg: #ffffff; --cc-toggle-enabled-icon-color: #ffffff; --cc-toggle-disabled-icon-color: #ffffff; - --cc-toggle-readonly-bg: var(--mantine-color-disabled, #f1f3f4); - --cc-toggle-readonly-knob-bg: var(--mantine-color-gray-0, #f8f9fa); - --cc-toggle-readonly-knob-icon-color: transparent; + --cc-toggle-readonly-bg: #f1f3f4; + --cc-toggle-readonly-knob-bg: #79747e; + --cc-toggle-readonly-knob-icon-color: #f1f3f4; --cc-section-category-border: #e0e0e0; @@ -70,18 +69,17 @@ --cc-separator-border-color: #555555; - /* Toggle colors mirror Mantine Switch (dark scheme) */ - --cc-toggle-on-bg: var(--mantine-primary-color-filled, #4dabf7); - --cc-toggle-off-bg: var(--mantine-color-dark-5, #555555); - --cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff); - --cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff); + --cc-toggle-on-bg: #4dabf7; + --cc-toggle-off-bg: #667481; + --cc-toggle-on-knob-bg: #2d2d2d; + --cc-toggle-off-knob-bg: #2d2d2d; --cc-toggle-enabled-icon-color: #2d2d2d; --cc-toggle-disabled-icon-color: #2d2d2d; - --cc-toggle-readonly-bg: var(--mantine-color-disabled, #555555); - --cc-toggle-readonly-knob-bg: var(--mantine-color-dark-3, #8e8e8e); - --cc-toggle-readonly-knob-icon-color: transparent; + --cc-toggle-readonly-bg: #555555; + --cc-toggle-readonly-knob-bg: #8e8e8e; + --cc-toggle-readonly-knob-icon-color: #555555; --cc-section-category-border: #555555; @@ -178,16 +176,9 @@ color: var(--cc-primary-color) !important; } -/* Banner sits above the chat FAB but behind all modals and onboarding; value - is Z_INDEX_COOKIE_CONSENT_BANNER, set as this variable by useCookieConsent */ +/* Lower z-index so cookie banner appears behind onboarding modals */ #cc-main { - z-index: var(--z-index-cookie-consent) !important; -} - -/* Preferences dialog sits above the settings modal it opens from; value is - Z_INDEX_COOKIE_PREFERENCES_MODAL, set as this variable by useCookieConsent */ -.show--preferences #cc-main { - z-index: var(--z-index-cookie-preferences) !important; + z-index: 100 !important; } /* Ensure consent modal text is visible in both themes */ @@ -212,63 +203,3 @@ #cc-main .cm__link { color: var(--cc-primary-color) !important; } - -/* ── Category toggles restyled to match Mantine Switch (size sm) ────────── - Mantine sm metrics: 38×20 track, 14px plain thumb, 2.5px inline padding, - 150ms ease transitions, no icon inside the thumb. Colors come from the - --cc-toggle-* variables above, which point at the Mantine palette. */ -#cc-main .section__toggle, -#cc-main .section__toggle-wrapper, -#cc-main .toggle__icon, -#cc-main .toggle__label { - width: 38px !important; - height: 20px !important; - border-radius: 1000px !important; -} - -/* Track: flat fill, no outline ring or border */ -#cc-main .toggle__icon { - border: none !important; - box-shadow: none !important; - transition: background-color 150ms ease !important; -} - -#cc-main .section__toggle:checked ~ .toggle__icon { - border: none !important; - box-shadow: none !important; -} - -/* Always-enabled categories = Mantine disabled switch (must out-prioritise - the !important checked-track rule above) */ -#cc-main .section__toggle:checked:disabled ~ .toggle__icon { - background: var(--cc-toggle-readonly-bg) !important; - border: none !important; - box-shadow: none !important; -} - -#cc-main .section__toggle:disabled { - cursor: not-allowed !important; -} - -/* Thumb: small plain circle, vertically centred, no drop shadow */ -#cc-main .toggle__icon-circle { - width: 14px !important; - height: 14px !important; - top: 3px !important; - left: 2.5px !important; - box-shadow: none !important; - transition: - transform 150ms ease, - background-color 150ms ease !important; -} - -/* Checked thumb travel: 38 − 14 − 2.5 = 21.5px end position */ -#cc-main .section__toggle:checked ~ .toggle__icon .toggle__icon-circle { - transform: translateX(19px) !important; -} - -/* Mantine switches have no check/cross glyph inside the thumb */ -#cc-main .toggle__icon-on, -#cc-main .toggle__icon-off { - display: none !important; -} diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5cf1de401..4310a4b7a 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -50,9 +50,12 @@ help = "Help" imgPrompt = "Select Image(s)" incorrectPasswordMessage = "Current password is incorrect." info = "Info" +insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." +loadingCredits = "Checking credits..." +loadingProStatus = "Checking subscription status..." logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" @@ -63,6 +66,7 @@ noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" nothingToUndo = "Nothing to undo" +noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" openInNewWindow = "Open in new window" @@ -162,7 +166,7 @@ logOut = "Log out" signedInAs = "Signed in as: {{email}}" [add-page-numbers] -tags = "paginate,label,organise,index" +tags = "paginate,label,organize,index" [addAttachments] tags = "embed,attach,include,attachments,attach files,embed files,include files,add files,file attachment,associated files,supplementary files" @@ -271,7 +275,8 @@ saved = "Saved" text = "Text" [addPageNumbers] -customize = "Customise Appearance" +tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool" +customize = "Customize Appearance" customNumberDesc = "e.g., \"Page {n}\" or leave blank for just numbers" fontName = "Font Name" fontSize = "Font Size" @@ -281,7 +286,6 @@ positionAndPages = "Position & Pages" preview = "Position Selection" previewDisclaimer = "Preview is approximate. Final output may vary due to PDF font metrics." submit = "Add Page Numbers" -tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool" title = "Add Page Numbers" zeroPad = "Zero‑pad Width (Bates Stamping)" zeroPadTooltip = "Zero‑pad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable." @@ -299,9 +303,9 @@ title = "Page Number Results" 6 = "Custom Text Format" [addPassword] +tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access" desc = "Encrypt your PDF document with a password." submit = "Encrypt" -tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access" title = "Add Password" [addPassword.encryption.keyLength] @@ -347,9 +351,6 @@ title = "Password Types" text = "These permissions control what users can do with the PDF. Most effective when combined with an owner password." title = "Change Permissions" -[addStamp] -tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" - [AddStampRequest] alphabet = "Alphabet" clickToExpand = "Click to expand" @@ -386,7 +387,7 @@ stampText = "Stamp Text" stampTextDescription = "Use dynamic variables below. Use @@ for literal @. Use \\n for new lines." stampType = "Stamp Type" submit = "Submit" -tags = "Stamp, Add image, centre image, Watermark, PDF, Embed, Customise,Customise" +tags = "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise" timeDesc = "Current time" title = "Stamp PDF" totalPagesDesc = "Total number of pages" @@ -420,9 +421,6 @@ header = "Add text to PDFs" tags = "text,annotation,label" title = "Add Text" -[addText.canvas] -colorPickerTitle = "Choose stroke colour" - [addText.error] failed = "An error occurred while adding text to the PDF." @@ -442,6 +440,25 @@ resume = "Resume placement" [addText.results] title = "Add Text Results" +[addText.step] +createDesc = "Enter the text you want to add" +place = "Place text" +placeDesc = "Click on the PDF to add your text" + +[addText.steps] +configure = "Configure Text" + +[addText.text] +colorLabel = "Text colour" +fontLabel = "Font" +fontSizeLabel = "Font size" +fontSizePlaceholder = "Type or select font size (8-200)" +name = "Text content" +placeholder = "Enter the text you want to add" + +[addText.canvas] +colorPickerTitle = "Choose stroke colour" + [addText.saved] defaultCanvasLabel = "Drawing signature" defaultImageLabel = "Uploaded signature" @@ -464,22 +481,6 @@ canvas = "Drawing" image = "Upload" text = "Text" -[addText.step] -createDesc = "Enter the text you want to add" -place = "Place text" -placeDesc = "Click on the PDF to add your text" - -[addText.steps] -configure = "Configure Text" - -[addText.text] -colorLabel = "Text colour" -fontLabel = "Font" -fontSizeLabel = "Font size" -fontSizePlaceholder = "Type or select font size (8-200)" -name = "Text content" -placeholder = "Enter the text you want to add" - [addText.type] canvas = "Canvas" image = "Image" @@ -487,10 +488,11 @@ saved = "Saved" text = "Text" [adjust-contrast] -tags = "colour-correction,tune,modify,enhance,colour-correction" +tags = "color-correction,tune,modify,enhance,colour-correction" [adjustContrast] -adjustColors = "Adjust Colours" +tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance" +adjustColors = "Adjust Colors" basic = "Basic Adjustments" blue = "Blue" brightness = "Brightness:" @@ -500,11 +502,10 @@ green = "Green" noPreview = "Select a PDF to preview" red = "Red" saturation = "Saturation:" -tags = "contrast,brightness,saturation,adjust colours,colour correction,enhance,lighten,darken,improve quality,colour balance,hue,vibrance" -title = "Adjust Colours/Contrast" +title = "Adjust Colors/Contrast" [adjustContrast.error] -failed = "Failed to adjust colours/contrast" +failed = "Failed to adjust colors/contrast" [adjustContrast.results] title = "Adjusted PDF" @@ -578,8 +579,8 @@ processing = "Processing" title = "Advanced" [admin.settings.advanced.disableSanitize] -description = "WARNING: Security risk - disabling HTML sanitisation can lead to XSS vulnerabilities" -label = "Disable HTML Sanitisation" +description = "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities" +label = "Disable HTML Sanitization" [admin.settings.advanced.enableAlphaFunctionality] description = "Enable experimental and alpha-stage features (may be unstable)" @@ -621,7 +622,7 @@ description = "Maximum execution time" label = "Timeout (minutes)" [admin.settings.advanced.tempFileManagement] -description = "Configure temporary file storage and cleanup behaviour" +description = "Configure temporary file storage and cleanup behavior" label = "Temp File Management" [admin.settings.advanced.tempFileManagement.baseTmpDir] @@ -703,7 +704,7 @@ mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" s mobileScannerPageFormat = "Page Format" mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." mobileScannerStretchToFit = "Stretch to Fit" -mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centred with preserved aspect ratio." +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." pageFormatA4 = "A4 (210×297mm)" pageFormatKeep = "Keep (Original Dimensions)" pageFormatLetter = "Letter (8.5×11in)" @@ -724,7 +725,7 @@ mobileScannerImageResolutionDesc = "Resolution of uploaded images. \"Reduced\" s mobileScannerPageFormat = "Page Format" mobileScannerPageFormatDesc = "PDF page size for converted images. \"Keep\" uses original image dimensions." mobileScannerStretchToFit = "Stretch to Fit" -mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centred with preserved aspect ratio." +mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio." note = "Note: Requires Frontend URL to be configured. " pageFormatA4 = "A4 (210×297mm)" pageFormatKeep = "Keep (Original Dimensions)" @@ -903,8 +904,8 @@ label = "Disabled Endpoints" placeholder = "Select endpoints to disable" [admin.settings.enterpriseRequired] -message = "An Enterprise licence is required to access {{featureName}}. You are viewing demo data for reference." -title = "Enterprise Licence Required" +message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference." +title = "Enterprise License Required" [admin.settings.features] description = "Configure optional features and functionality." @@ -919,8 +920,8 @@ description = "Enable server-side certificate for \"Sign with Stirling-PDF\" opt label = "Enable Server Certificate" [admin.settings.features.serverCertificate.organizationName] -description = "Organisation name for generated certificates" -label = "Organisation Name" +description = "Organization name for generated certificates" +label = "Organization Name" [admin.settings.features.serverCertificate.regenerateOnStartup] description = "Generate new certificate on each application startup" @@ -1063,7 +1064,7 @@ description = "URL or filename to cookie policy" label = "Cookie Policy" [admin.settings.legal.disclaimer] -message = "By customising these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations." +message = "By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations." title = "Legal Responsibility Warning" [admin.settings.legal.impressum] @@ -1130,10 +1131,6 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint." title = "MCP Server" -[admin.settings.mcp.acceptedAudiences] -description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated." -label = "Additional accepted audiences (optional)" - [admin.settings.mcp.allowedOps] description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below." label = "Allowed tools" @@ -1161,7 +1158,7 @@ tipApiKey = "Tip: API-key mode needs no external IdP - simplest for self-host. T title = "Connect an MCP client" [admin.settings.mcp.issuerUri] -description = "Your OAuth2 authorisation server (must publish /.well-known/openid-configuration). Required when enabled." +description = "Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled." label = "OAuth Issuer URL" [admin.settings.mcp.jwksUri] @@ -1194,55 +1191,55 @@ message = "Plans data is not available at the moment." title = "No data available" [admin.settings.premium] -description = "Configure your premium or enterprise licence key." -license = "Licence Configuration" -noInput = "Please provide a licence key or file" +description = "Configure your premium or enterprise license key." +license = "License Configuration" +noInput = "Please provide a license key or file" title = "Premium & Enterprise" [admin.settings.premium.currentLicense] -file = "Source: Licence file ({{path}})" -key = "Source: Licence key" -noInput = "Please provide a licence key or upload a certificate file" +file = "Source: License file ({{path}})" +key = "Source: License key" +noInput = "Please provide a license key or upload a certificate file" success = "Success" -title = "Active Licence" +title = "Active License" type = "Type: {{type}}" [admin.settings.premium.enabled] -description = "Enable licence key checks for pro/enterprise features" +description = "Enable license key checks for pro/enterprise features" label = "Enable Premium Features" [admin.settings.premium.file] -choose = "Choose Licence File" -description = "Upload your .lic or .cert licence file from offline purchases" -label = "Licence Certificate File" +choose = "Choose License File" +description = "Upload your .lic or .cert license file from offline purchases" +label = "License Certificate File" selected = "Selected: {{filename}} ({{size}})" -successMessage = "Licence file uploaded and activated successfully. No restart required." +successMessage = "License file uploaded and activated successfully. No restart required." [admin.settings.premium.inputMethod] file = "Certificate File" -text = "Licence Key" +text = "License Key" [admin.settings.premium.key] -description = "Enter your premium or enterprise licence key. Premium features will be automatically enabled when a key is provided." -label = "Licence Key" -success = "Licence Key Saved" -successMessage = "Your licence key has been activated successfully. No restart required." +description = "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided." +label = "License Key" +success = "License Key Saved" +successMessage = "Your license key has been activated successfully. No restart required." [admin.settings.premium.key.overwriteWarning] -line1 = "Overwriting your current licence key cannot be undone." -line2 = "Your previous licence will be permanently lost unless you have backed it up elsewhere." -line3 = "Important: Keep licence keys private and secure. Never share them publicly." -title = "⚠️ Warning: Existing Licence Detected" +line1 = "Overwriting your current license key cannot be undone." +line2 = "Your previous license will be permanently lost unless you have backed it up elsewhere." +line3 = "Important: Keep license keys private and secure. Never share them publicly." +title = "⚠️ Warning: Existing License Detected" [admin.settings.premium.licenseKey] -info = "If you have a licence key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features." -toggle = "Got a licence key or certificate file?" +info = "If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features." +toggle = "Got a license key or certificate file?" [admin.settings.premium.movedFeatures] auditLogging = "<0>Audit Logging (ENTERPRISE) - Security" customMetadata = "<0>Custom Metadata (PRO) - General" databaseConfiguration = "<0>Database Configuration (ENTERPRISE) - Database" -message = "Premium and Enterprise features are now organised in their respective sections:" +message = "Premium and Enterprise features are now organized in their respective sections:" title = "Premium Features Distributed" [admin.settings.privacy] @@ -1469,7 +1466,7 @@ description = "Allow users to create multi-participant document signing sessions label = "Enable Group Signing (Alpha)" [admin.settings.telegram] -description = "Configure Telegram bot connectivity, access controls, and feedback behaviour." +description = "Configure Telegram bot connectivity, access controls, and feedback behavior." title = "Telegram Bot" [admin.settings.telegram.accessControl] @@ -1606,9 +1603,6 @@ paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." title = "Do you want to help make Stirling PDF better?" -[annotate] -tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" - [annotation] annotationStyle = "Annotation style" backgroundColor = "Background colour" @@ -1682,8 +1676,8 @@ configureAudit = "Configure Audit Logging" configureAuditMessage = "Adjust audit logging level, retention period, and other settings in the Security & Authentication section." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." -enterpriseRequired = "Enterprise Licence Required" -enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise licence to access audit logs and analytics." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." goToSettings = "Go to Audit Settings" notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." @@ -1813,7 +1807,7 @@ user = "User" [auto-rename] description = "Automatically finds the title from your PDF content and uses it as the filename." submit = "Auto Rename" -tags = "auto-detect,header-based,organise,relabel" +tags = "auto-detect,header-based,organize,relabel" title = "Auto Rename" [auto-rename.error] @@ -1838,6 +1832,7 @@ bullet3 = "Keeps the original name if no suitable title is found" title = "Smart Renaming" [automate] +tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" copyToSaved = "Copy to Saved" desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." export = "Export" @@ -1847,7 +1842,6 @@ importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}" importSuccess = "Imported automation: {{name}}" invalidStep = "Invalid step" reviewTitle = "Automation Results" -tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" title = "Automate" [automate.config] @@ -1935,8 +1929,8 @@ unnamed = "Unnamed Automation" [automation.suggested] emailPreparation = "Email Preparation" emailPreparationDesc = "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy." -prePublishSanitization = "Pre-publish Sanitisation" -prePublishSanitizationDesc = "Sanitisation workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online." +prePublishSanitization = "Pre-publish Sanitization" +prePublishSanitizationDesc = "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online." processImages = "Process Images" processImagesDesc = "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." securePdfIngestion = "Secure PDF Ingestion" @@ -1945,16 +1939,16 @@ secureWorkflow = "Security Workflow" secureWorkflowDesc = "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default." [autoRename] +tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" description = "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." -tags = "auto-detect,header-based,organise,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" [autoSizeSplitPDF] -tags = "pdf,split,document,organisation" +tags = "pdf,split,document,organization" [autoSplitPDF] dividerDownload2 = "Download 'Auto Splitter Divider (with instructions).pdf'" duplexMode = "Duplex Mode (Front and back scanning)" -tags = "QR-based,separate,scan-segment,organise" +tags = "QR-based,separate,scan-segment,organize" title = "Auto Split PDF" [backendHealth] @@ -2155,11 +2149,11 @@ header = "Certificate Types" [certificateChoice.tooltip.organization] bullet1 = "Managed by system administrators" -bullet2 = "Shared across authorised users" +bullet2 = "Shared across authorized users" bullet3 = "Represents company identity, not individual" bullet4 = "Best for: Official documents, team signatures" -description = "A shared certificate provided by your organisation. Used for company-wide signing authority." -title = "Organisation Certificate" +description = "A shared certificate provided by your organization. Used for company-wide signing authority." +title = "Organization Certificate" [certificateChoice.tooltip.personal] bullet1 = "Generated automatically on first use" @@ -2186,7 +2180,7 @@ choosePfxFile = "Choose PFX File" choosePrivateKey = "Choose Private Key File" declined = "Declined" fetchFailed = "Failed to load signing data" -finalized = "Finalised" +finalized = "Finalized" location = "Location" logoTitle = "Logo" name = "Name" @@ -2196,7 +2190,7 @@ pageNumber = "Page Number" password = "Certificate Password" passwordOptional = "Leave empty if no password" pending = "Pending" -readyToFinalize = "Ready to finalise" +readyToFinalize = "Ready to finalize" reason = "Reason" serverCertMessage = "Using server certificate - no files or password required" showLogo = "Show Logo" @@ -2264,8 +2258,8 @@ reasonHelp = "Pre-set a signing reason for these participants (optional, they ca reasonPlaceholder = "e.g. Approval, Review..." [certSign.collab.finalize] -button = "Finalise and Load Signed PDF" -early = "Finalise with Current Signatures" +button = "Finalize and Load Signed PDF" +early = "Finalize with Current Signatures" [certSign.collab.participant] certInvalid = "✗ {{error}}" @@ -2288,7 +2282,7 @@ deleted = "Session deleted" deleteError = "Failed to delete session" deleteSession = "Delete Session" dueDate = "Due Date" -finalizeError = "Failed to finalise session" +finalizeError = "Failed to finalize session" loadPdfError = "Failed to load signed PDF" loadSignedPdf = "Load Signed PDF into Active Files" messageLabel = "Message" @@ -2304,7 +2298,7 @@ workbenchTitle = "Session Management" [certSign.collab.sessionList] active = "Active" -finalized = "Finalised" +finalized = "Finalized" [certSign.collab.signatureSettings] description = "Configure how signatures will appear for all participants" @@ -2336,7 +2330,7 @@ noCertificate = "Please select a certificate file" noSignatures = "Please place at least one signature on the PDF" p12File = "P12/PFX Certificate File" password = "Certificate Password" -penColor = "Pen Colour" +penColor = "Pen Color" penSize = "Pen Size: {{size}}px" reason = "Reason (Optional)" reasonPlaceholder = "Why are you signing?" @@ -2351,15 +2345,15 @@ signatureTextPlaceholder = "Enter your name..." signatureTypeLabel = "Signature Type" signButton = "Sign Document" signingTitle = "Signing" -textColor = "Text Colour" +textColor = "Text Color" typeSignature = "Type your name to create a signature" uploadCert = "Custom Certificate" uploadCertDesc = "Use your own P12/PFX certificate" uploadSignature = "Upload your signature image" usePersonalCert = "Personal Certificate" usePersonalCertDesc = "Auto-generated for your account" -useServerCert = "Organisation Certificate" -useServerCertDesc = "Shared organisation certificate" +useServerCert = "Organization Certificate" +useServerCertDesc = "Shared organization certificate" workbenchTitle = "Sign Request" [certSign.collab.signRequest.canvas] @@ -2427,7 +2421,7 @@ visible = "Visible" yourSignatures = "Your Signatures ({{count}})" [certSign.collab.signRequest.text] -colorLabel = "Colour" +colorLabel = "Color" fontLabel = "Font" fontSizeLabel = "Size" fontSizePlaceholder = "16" @@ -2453,7 +2447,7 @@ panelPeople = "People" [certSign.sessions] deleted = "Session deleted" fetchFailed = "Failed to load session details" -finalized = "Session finalised" +finalized = "Session finalized" loaded = "Signed PDF loaded" pdfNotReady = "PDF Not Ready" pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a moment." @@ -2526,9 +2520,9 @@ ssoManaged = "Your account is managed by your identity provider." title = "Change Credentials" [changeMetadata] +tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties" filenamePrefix = "metadata" submit = "Change" -tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties" [changeMetadata.advanced] title = "Advanced Options" @@ -2634,9 +2628,9 @@ true = "True" unknown = "Unknown" [changePermissions] +tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" desc = "Change document restrictions and permissions." submit = "Change Permissions" -tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" title = "Change Permissions" [changePermissions.error] @@ -2684,16 +2678,11 @@ resize = "Resize chat panel" [chat.actions] copy = "Copy message" -[chat.fab] -close = "Close chat" -open = "Open Stirling AI assistant" - [chat.header] agentMenu = "Stirling agent options" clearChat = "Clear chat" [chat.input] -disclaimer = "AI can make mistakes. Be sure to verify the output before sharing." placeholder = "What do you want to do?" send = "Send message" @@ -2706,18 +2695,18 @@ executing_tool_single = "Running {{tool}}..." executing_tool_step = "Running {{tool}} (step {{step}} of {{total}})..." extracting_content = "Extracting content from your documents..." processing = "Processing extracted content..." -ranForMinutes = "Ran for {{count}} minutes" -ranForMinutes_one = "Ran for 1 minute" -ranForMinutes_other = "Ran for {{count}} minutes" -ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec" -ranForSeconds = "Ran for {{count}} seconds" -ranForSeconds_one = "Ran for 1 second" -ranForSeconds_other = "Ran for {{count}} seconds" thinking = "Thinking..." whole_doc_compression_round = "Consolidating notes..." whole_doc_read_done = "Finished reading the document..." whole_doc_read_started = "Reading the document..." whole_doc_slice_done = "Reading the document... ({{percent}}% complete)" +ranForSeconds = "Ran for {{count}} seconds" +ranForSeconds_one = "Ran for 1 second" +ranForSeconds_other = "Ran for {{count}} seconds" +ranForMinutes = "Ran for {{count}} minutes" +ranForMinutes_one = "Ran for 1 minute" +ranForMinutes_other = "Ran for {{count}} minutes" +ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec" [chat.quickActions] browseYourFiles = "Browse your files" @@ -2730,6 +2719,8 @@ mergeMany = "Merge these {{count}} documents into 1" moreFiles = "+{{count}} more" openFromComputer = "Open from computer" removeFile = "Remove {{name}}" +rotateMany = "Rotate these documents" +rotateOne = "Rotate this document" splitOne = "Split this document" [chat.responses] @@ -2740,12 +2731,15 @@ need_clarification = "Could you clarify your request?" not_found = "I couldn't find the requested information." processing = "Processing ({{outcome}})..." unsupported_capability = "Unsupported capability: {{capability}}" -usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going." [chat.toolsUsed] summary = "Ran {{count}} tools" unknownTool = "Unknown tool" +[chat.fab] +close = "Close chat" +open = "Open Stirling AI assistant" + [cloudBadge] tooltip = "This operation will use your cloud credits" @@ -2767,6 +2761,7 @@ copy = "Copy" done = "Done" error = "Error" expand = "Expand" +learnMore = "Learn more" loading = "Loading..." next = "Next" operation = "this operation" @@ -2901,9 +2896,9 @@ unlinkedTitle = "Independent scroll & pan enabled" message = "These documents appear highly dissimilar. Comparison was stopped to save time." [compress] +tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size" desc = "Compress PDFs to reduce their file size." submit = "Compress" -tags = "shrink,reduce,optimise,compress,smaller,downsize,file size,reduce size,minimise,make smaller,decrease size,optimise size" title = "Compress" [compress.compressionLevel] @@ -2915,7 +2910,7 @@ range7to9 = "Higher values reduce file size significantly but may reduce image c failed = "An error occurred while compressing the PDF." [compress.grayscale] -label = "Apply Greyscale for Compression" +label = "Apply Grayscale for Compression" [compress.linearize] label = "Linearize PDF for fast web viewing" @@ -2945,7 +2940,7 @@ title = "Description" [compress.tooltip.grayscale] text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents." -title = "Greyscale" +title = "Grayscale" [compress.tooltip.header] title = "Compress Settings Overview" @@ -2965,7 +2960,6 @@ tags = "squish,small,tiny" [config] plan = "Plan" -team = "Team" [config.account.overview] confirmDelete = "Delete My Account" @@ -2982,7 +2976,7 @@ signedInAs = "Signed in as" title = "Account Settings" [config.account.profilePicture] -description = "Upload an image to personalise your account." +description = "Upload an image to personalize your account." help = "PNG, JPG, or WebP up to 2MB." remove = "Remove" sizeError = "Please select an image smaller than 2MB." @@ -3061,9 +3055,8 @@ warning = "⚠️ Warning: This action will generate new API keys and make your [config.mcp] description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf." -guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf." navLabel = "MCP Server" -tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using the Stirling PDF API and Automation." +tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly." title = "MCP Server" viewApiKeys = "View API keys" @@ -3083,6 +3076,14 @@ addTo = "Add to" hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy." title = "Connect your AI assistant" +[config.mcp.tools] +ai = "AI" +convert = "Convert" +misc = "Misc" +pages = "Pages" +security = "Security" +title = "What your assistant can do" + [config.overview] description = "Current application settings and configuration details." error = "Error" @@ -3097,6 +3098,7 @@ security = "Security Configuration" system = "System Configuration" [convert] +tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" blackwhite = "Black & White" @@ -3137,7 +3139,7 @@ includeAttachments = "Include email attachments" maintainAspectRatio = "Maintain Aspect Ratio" maxAttachmentSize = "Maximum attachment size (MB)" multiple = "Multiple" -optimizeForEbook = "Optimise PDF for ebook readers (uses Ghostscript)" +optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)" output = "Output" outputFormat = "Output Format" pdfaDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step." @@ -3156,7 +3158,6 @@ strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification) svgPdfOptions = "SVG to PDF Options" svgToPdf = "SVG → PDF" svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size." -tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" targetFormatPlaceholder = "Target format" title = "Convert" webOptions = "Web to PDF Options" @@ -3169,18 +3170,18 @@ includePageNumbers = "Include page numbers" includePageNumbersDesc = "Add page numbers to the generated PDF" includeTableOfContents = "Include table of contents" includeTableOfContentsDesc = "Add a generated table of contents to the resulting PDF" -optimizeForEbookPdf = "Optimise for ebook readers" -optimizeForEbookPdfDesc = "Optimise the PDF for eBook reading (smaller file size, better rendering on eInk devices)" +optimizeForEbookPdf = "Optimize for ebook readers" +optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)" [convert.epubOptions] detectChapters = "Detect chapters" detectChaptersDesc = "Detect headings that look like chapters and insert EPUB page breaks" -kindleEink = "Kindle e-Ink (text optimised)" +kindleEink = "Kindle e-Ink (text optimized)" outputFormat = "Output format" outputFormatDesc = "Choose the output format for the ebook" tabletPhone = "Tablet/Phone (with images)" targetDevice = "Target device" -targetDeviceDesc = "Choose an output profile optimised for the reader device" +targetDeviceDesc = "Choose an output profile optimized for the reader device" [cookieBanner.popUp] acceptAllBtn = "Okay" @@ -3199,7 +3200,7 @@ closeIconLabel = "Close modal" savePreferencesBtn = "Save preferences" serviceCounterLabel = "Service|Services" subtitle = "Cookie Usage" -title = "Consent Preferences Centre" +title = "Consent Preferences Center" [cookieBanner.preferencesModal.analytics] description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with." @@ -3283,7 +3284,7 @@ teamSubscription = "Team" titleExhausted = "You've used your free credits" titleExhaustedPro = "You have run out of credits" unlimitedApiAccess = "Unlimited API access" -unlimitedMonthlyCredits = "Site Licence" +unlimitedMonthlyCredits = "Site License" unlimitedSeats = "Unlimited seats" [crop] @@ -3376,18 +3377,6 @@ docsLink = "View installation documentation" message = "Stirling-PDF does not have permission to update itself on this machine." title = "Administrator permissions required" -[devAirgapped] -tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone" - -[devApi] -tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting" - -[devFolderScanning] -tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring" - -[devSsoGuide] -tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP" - [dropdownList] searchPlaceholder = "Search..." @@ -3396,8 +3385,8 @@ edit = "Edit" editSecretValue = "Edit secret value" [editTableOfContents] -submit = "Apply table of contents" tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline" +submit = "Apply table of contents" [editTableOfContents.actions] clipboardUnavailable = "Clipboard access is not available in this browser." @@ -3531,8 +3520,8 @@ title = "Settings" tags = "extract" [extractPages] -submit = "Extract Pages" tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages" +submit = "Extract Pages" title = "Extract Pages" [extractPages.error] @@ -3780,7 +3769,6 @@ selectFile = "Select file {{name}}" shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." shareManage = "Manage sharing" showDetails = "Show details" -signInRequired = "Sign in to use cloud storage." summary = "{{count}} items" tree = "Folders" upload = "Upload" @@ -3931,8 +3919,8 @@ welcomeMessage = "For security reasons, you must change your password on your fi welcomeTitle = "Welcome!" [flatten] +tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable" submit = "Flatten" -tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalise form,lock form,disable editing,convert to image,non-editable" title = "Flatten" [flatten.error] @@ -4136,13 +4124,13 @@ header = "About Group Signing" [groupSigning.tooltip.finalization] bullet1 = "All signatures are applied in the participant order you specified" -bullet2 = "You can finalise with partial signatures if needed" -bullet3 = "Once finalised, the session cannot be modified" -description = "Once all participants have signed (or you choose to finalise early), you can generate the final signed PDF." -title = "Finalisation Process" +bullet2 = "You can finalize with partial signatures if needed" +bullet3 = "Once finalized, the session cannot be modified" +description = "Once all participants have signed (or you choose to finalize early), you can generate the final signed PDF." +title = "Finalization Process" [groupSigning.tooltip.roles] -bullet1 = "Owner (you): Creates session, configures signature defaults, finalises document" +bullet1 = "Owner (you): Creates session, configures signature defaults, finalizes document" bullet2 = "Participants: Create their signature, choose certificate, place on PDF" bullet3 = "Participants cannot modify signature visibility, reason, or location settings" description = "You control the signature appearance settings for all participants." @@ -4192,8 +4180,8 @@ tags = "text,annotation,label,add text,insert text,place text,text box,add label title = "Add Text" [home.adjustContrast] -desc = "Adjust Colours/Contrast, Saturation and Brightness of a PDF" -tags = "contrast,brightness,saturation,adjust colours,colour correction,enhance,lighten,darken,improve quality,colour balance,hue,vibrance" +desc = "Adjust Colors/Contrast, Saturation and Brightness of a PDF" +tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance" title = "Adjust Colours/Contrast" [home.annotate] @@ -4208,7 +4196,7 @@ title = "Automate" [home.autoRename] desc = "Auto renames a PDF file based on its detected header" -tags = "auto-detect,header-based,organise,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" +tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" title = "Auto Rename PDF File" [home.autoSizeSplitPDF] @@ -4246,7 +4234,7 @@ title = "Compare" [home.compress] desc = "Compress PDFs to reduce their file size." -tags = "shrink,reduce,optimise,compress,smaller,downsize,file size,reduce size,minimise,make smaller,decrease size,optimise size" +tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size" title = "Compress" [home.convert] @@ -4296,7 +4284,7 @@ title = "Extract Pages" [home.flatten] desc = "Remove all interactive elements and forms from a PDF" -tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalise form,lock form,disable editing,convert to image,non-editable" +tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable" title = "Flatten" [home.formFill] @@ -4329,12 +4317,12 @@ workspace = "Workspace" [home.multiTool] desc = "Merge, Rotate, Rearrange, Split, and Remove pages" -tags = "multiple,tools,multi-tool,all-in-one,swiss army,page organiser,page editor,edit pages,manage pages,organise,reorganize" +tags = "multiple,tools,multi-tool,all-in-one,swiss army,page organizer,page editor,edit pages,manage pages,organize,reorganize" title = "PDF Multi Tool" [home.ocr] desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text." -tags = "extract,scan,OCR,optical character recognition,text recognition,scan to text,image to text,scanned document,searchable PDF,make searchable,extract text,recognise text,read scanned" +tags = "extract,scan,OCR,optical character recognition,text recognition,scan to text,image to text,scanned document,searchable PDF,make searchable,extract text,recognize text,read scanned" title = "OCR / Cleanup scans" [home.overlay-pdfs] @@ -4353,7 +4341,7 @@ tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes" title = "Add AI comments" [home.pdfOrganiser] -tags = "organise,rearrange,reorder,organise,arrange pages,sort,move pages,delete pages,remove pages,page management,page organiser,page organiser,resequence" +tags = "organize,rearrange,reorder,organise,arrange pages,sort,move pages,delete pages,remove pages,page management,page organizer,page organiser,resequence" title = "Organise" [home.pdfTextEditor] @@ -4408,7 +4396,7 @@ title = "Remove Password" [home.reorganizePages] desc = "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." -tags = "rearrange,reorder,organise,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" +tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" title = "Reorganize Pages" [home.repair] @@ -4418,7 +4406,7 @@ title = "Repair" [home.replaceColor] desc = "Replace or invert colours in PDF documents" -tags = "replace colour,invert colour,colour replacement,swap colours,change colour,invert,negative,colour swap,find and replace colour,convert colour,colour change" +tags = "replace color,invert color,color replacement,swap colors,change color,invert,negative,color swap,find and replace color,convert color,color change" title = "Replace & Invert Colour" [home.rotate] @@ -4428,7 +4416,7 @@ title = "Rotate" [home.sanitize] desc = "Remove potentially harmful elements from PDF files" -tags = "clean,purge,remove,sanitise,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" +tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" title = "Sanitise" [home.scalePages] @@ -4453,7 +4441,7 @@ title = "Show Javascript" [home.sign] desc = "Adds signature to PDF by drawing, text or image" -tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorise,endorse,ink signature,handwriting" +tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" title = "Sign" [home.split] @@ -4577,7 +4565,6 @@ signIn = "Sign In" accountCreatedSuccess = "Account created successfully! You can now sign in." backToSignIn = "Back to sign in" changePasswordWarning = "Please change your password after logging in for the first time" -createAccount = "Create an account" credentialsUpdated = "Your credentials have been updated. Please sign in again." defaultCredentials = "Default Login Credentials" email = "Email" @@ -4607,6 +4594,7 @@ sending = "Sending…" sendMagicLink = "Send Magic Link" sendResetLink = "Send reset link" sessionExpired = "Your session has expired. Please sign in again." +createAccount = "Create an account" signin = "Sign in" signingIn = "Signing in..." signInWith = "Sign in with" @@ -4744,34 +4732,6 @@ title = "Authentication Failed" message = "You can close this window and return to Stirling PDF." title = "Authentication Successful" -[oauthConsent] -approve = "Allow access" -approving = "Allowing..." -decisionFailed = "Could not submit your decision. Please try again." -deny = "Deny" -denying = "Denying..." -loadFailed = "This authorisation request is invalid or has expired. Close this tab and try connecting again from the app." -loading = "Loading authorisation request..." -missingId = "Missing authorisation request. Start the connection from your app and try again." -redirecting = "Returning you to the app..." -requesting = "{{app}} wants to access your Stirling PDF account" -scopesIntro = "This will allow {{app}} to:" -signedInAs = "Signed in as {{name}}" -signInButton = "Sign in to continue" -signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app." -title = "Authorise access" -unknownApp = "A third-party application" - -[oauthConsent.access] -actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage" -tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents" - -[oauthConsent.scope] -email = "See your email address" -openid = "Confirm your identity" -phone = "See your phone number" -profile = "See your basic profile information" - [ocr] desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text." tags = "recognition,text,image,scan,read,identify,detection,editable" @@ -4889,6 +4849,15 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc title = "Download" titleWithOs = "Download for {{osLabel}}" +[onboarding.freeTrial] +afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access." +afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends." +body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing." +daysRemaining = "{{days}} days remaining" +daysRemainingSingular = "{{days}} day remaining" +title = "Your 30-Day Pro Trial" +trialEnds = "Trial ends {{date}}" + [onboarding.mfa] authenticationCode = "Authentication code" qrCodeLoading = "Generating your QR code…" @@ -4897,25 +4866,9 @@ qrCodeLoading = "Generating your QR code…" adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge." adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge." adminTitle = "Admin Overview" -userBody = "Invite teammates, assign roles, and keep your documents organised in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." +userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" -[onboarding.saas.freeEditor] -freeLine = "The editor is now completely free." -premium = "We've added loads of new features, including Policies and Agent Chat." -title = "Welcome to Stirling" - -[onboarding.saas.team] -addButton = "Add" -createBody = "Work on documents together: teammates share files, automations and your plan. Add the first member by email to create your team." -createTitle = "Create your team" -inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email and they'll get an invitation." -inviteTitle = "Invite members to your team" - -[onboarding.saas.usage] -body = "Automations, AI and API requests draw from your free allowance. Manual editing never counts against it." -title = "Your free Processor allowance" - [onboarding.securityCheck] message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." roleAdmin = "Admin" @@ -4924,9 +4877,9 @@ roleUser = "User" [onboarding.serverLicense] freeBody = "Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - unlimited seats and SSO support for $99/server/mo." -freeTitle = "Server Licence" +freeTitle = "Server License" overLimitBody = "Our licensing permits up to {{freeTierLimit}} users for free per server. You have {{overLimitUserCopy}} Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - unlimited seats, PDF text editing, and full admin control for $99/server/mo." -overLimitTitle = "Server Licence Needed" +overLimitTitle = "Server License Needed" seePlans = "See Plans →" upgrade = "Upgrade now →" @@ -5043,7 +4996,7 @@ right = "Right Margin" rows = "Rows" rowsPlaceholder = "Enter rows" submit = "Submit" -tags = "merge,composite,single-view,organise" +tags = "merge,composite,single-view,organize" title = "Multi Page Layout" top = "Top Margin" @@ -5186,213 +5139,6 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" -[payg.activity] -docs = "docs" -empty = "No billable activity yet this period." -subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." -title = "Recent billable activity" - -[payg.cap] -amount = "Cap amount" -custom = "Custom" -docsEstimate = "≈ {{docs}} processed PDFs / month" -docsRate = "at {{rate}} / PDF" -noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." -noCapLabel = "No cap" -noneShort = "No cap" -perMonth = "/ month" -save = "Update cap" -subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." -title = "Monthly spending cap" - -[payg.checkout] -connecting = "Connecting to Stripe…" -errorTitle = "Stripe error" - -[payg.checkout.error] -startFailed = "Couldn't start checkout session" - -[payg.checkout.mock] -backend = "Backend is in mock mode; no real Stripe session was created." -continue = "Continue with mock subscription" -noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured." -title = "Stripe Embedded Checkout (mock mode)" - -[payg.confirm] -body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs." -capValue = "{{symbol}}{{amount}} / month" -noCap = "No cap" -note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page." -summaryLabel = "Monthly ceiling" -title = "Welcome to the Processor plan" - -[payg.docHelp] -billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free." -chains = "Running the same file through several steps of one automation counts it once, not once per step." -perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one." -refunds = "If a job fails on its first step, the PDF is credited back automatically." -toggle = "What counts as a PDF?" - -[payg.error] -body = "We couldn't reach the billing service. Refresh the page to try again." -title = "Couldn't load your plan" - -[payg.exhausted] -body = "You've used all 500 of your free PDFs. Upgrade to Processor to keep going." -cta = "Go to billing" -title = "You've used your free PDFs" - -[payg.free.cta] -benefit1Body = "chain tools, schedule runs, batch process" -benefit1Title = "Automation pipelines" -benefit2Body = "summarise, classify, redact, AI-OCR" -benefit2Title = "AI tools" -benefit3Body = "call any Stirling endpoint programmatically" -benefit3Title = "API access" -button = "Turn on Processor →" -reassurance = "No minimum · Set a $0 cap to test · Cancel anytime" -subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control." -title = "Turn on the Processor plan" - -[payg.free.editor] -eyebrow = "Editor plan · Always free" - -[payg.free.header] -freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." -freeTitle = "Unlimited PDF editing" - -[payg.free.hero] -capSuffix = "/ {{limit}} free PDFs" -metaCategories = "Automation · AI · API requests" - -[payg.free.member] -ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." - -[payg.free.proc] -eyebrow = "Processor plan · metered" - -[payg.free.state] -approachingLimit = "Approaching limit" -limitReached = "Limit reached" -plentyLeft = "Plenty left" - -[payg.gates] -ai = "AI tools (AI Create, suggestions, AI-OCR)" -automation = "Automations & pipelines" -client = "Browser-only tools (viewer, page editor, file management)" -offsite = "Server tools (compress, OCR, convert, watermark…)" -pauses = "pauses at cap" -title = "What happens when the cap is reached" - -[payg.header] -eyebrow = "Processor plan · {{start}} – {{end}}" -freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." -freeLabel = "Always free" -freeTitle = "Unlimited PDF editing" -meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap." -meterLabel = "Metered" -meterTitle = "Automation · AI · API" - -[payg.member] -askLeader = "Only your team owner can change the cap." - -[payg.members] -docs = "PDFs" -subtitle = "Billable PDFs each teammate has processed this period." -title = "Team member usage" - -[payg.role] -leader = "Team owner" -member = "Member" - -[payg.signupRequired] -body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing." -cancel = "Not now" -cta = "Sign up free" -subtext = "Creating an account is free and takes a few seconds. No credit card required." -title = "Sign up to use {{category}}" - -[payg.signupRequired.category] -ai = "AI features" -api = "this tool" -automation = "automations" -default = "this feature" - -[payg.spendCapMeter] -capSuffix = "/ {{amount}} cap" -metaCategories = "Automation · AI · API spend" -resets = "Resets each billing period" - -[payg.state] -degraded = "Cap reached" -full = "Healthy" -warned = "Approaching cap" - -[payg.stripe] -open = "Open billing portal" -subtitle = "Receipts, invoices, payment method, billing currency." -title = "Manage billing in Stripe" - -[payg.stripe.toast.unavailable] -body = "Billing portal isn't available right now. Try again in a moment." -title = "Billing portal unavailable" - -[payg.upgrade] -backAria = "Back" -closeAria = "Close" - -[payg.upgrade.button] -cancel = "Cancel" -continue = "Continue →" -finish = "Finish" - -[payg.upgrade.cap] -help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." -title = "Set your monthly spend ceiling" -usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." - -[payg.upgrade.checkout] -capValue = "{{symbol}}{{amount}} / month" -edit = "Edit" -help = "Stripe handles your card details. Stirling never sees them." -loading = "Loading checkout…" -noCap = "No cap" -title = "Add your payment method" - -[payg.upgrade.help] -aiBody = "summarise, classify, redact, AI-OCR" -aiTitle = "AI tools" -apiBody = "programmatic access to any Stirling endpoint" -apiTitle = "API calls" -automationBody = "chained tools or scheduled runs that don't need clicks" -automationTitle = "Automation pipelines" -footnote = "Manual tools (viewing, editing, merging, splitting, watermarking, compressing, manual OCR) are always free, even past 500. The distinction is the type of work, not where you click." -title = "What we count toward billing" - -[payg.upgrade.promise] -body = "You only pay for automation pipelines, AI tools, and API calls, the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge." -highlight = "Manual tools stay free, always." - -[payg.upgrade.steps] -cap = "Set monthly ceiling" -payment = "Add payment method" - -[payg.upgrade.title] -confirm = "You're subscribed" -default = "Upgrade to Processor plan" - -[payg.usage] -breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}" -capLine = "{{cap}}/mo cap" -estBill = "≈ {{amount}} so far this period" -firstFree = "First {{free}} free" -noCap = "No monthly cap" -ofLimitProcessed = "/ {{limit}} PDFs processed" -processed = "PDFs processed" -resetsIn = "Resets in {{days}} days" -resetsTomorrow = "Resets tomorrow" -thisPeriod = "This billing period" - [payment] autoClose = "This window will close automatically..." canCloseWindow = "You can now close this window." @@ -5400,34 +5146,34 @@ checkoutInstructions = "Complete your purchase in the browser window that just o checkoutOpened = "Checkout Opened in Browser" closeLater = "I'll Do This Later" error = "Payment Error" -generatingLicense = "Generating your licence key..." -licenseActivated = "Licence activated! Your licence key has been saved. A confirmation email has been sent to your registered email address." -licenseDelayed = "Payment successful! Your licence is being generated. You will receive an email with your licence key shortly. If you don't receive it within 10 minutes, please contact support." -licenseDelayedMessage = "Your licence key is being generated. Please check your email shortly or contact support." +generatingLicense = "Generating your license key..." +licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address." +licenseDelayed = "Payment successful! Your license is being generated. You will receive an email with your license key shortly. If you don't receive it within 10 minutes, please contact support." +licenseDelayedMessage = "Your license key is being generated. Please check your email shortly or contact support." licenseInstructions = "This has been added to your installation. You will receive a copy in your email as well." -licenseKey = "Your Licence Key" -licensePollingError = "Payment successful but we couldn't retrieve your licence key automatically. Please check your email or contact support with your payment confirmation." -licenseRetrievalError = "Payment successful but licence retrieval failed. You will receive your licence key via email. Please contact support if you don't receive it within 10 minutes." -licenseSaveError = "Failed to save licence key. Please contact support with your licence key to complete activation." +licenseKey = "Your License Key" +licensePollingError = "Payment successful but we couldn't retrieve your license key automatically. Please check your email or contact support with your payment confirmation." +licenseRetrievalError = "Payment successful but license retrieval failed. You will receive your license key via email. Please contact support if you don't receive it within 10 minutes." +licenseSaveError = "Failed to save license key. Please contact support with your license key to complete activation." monthly = "Monthly" -paymentCanceled = "Payment was cancelled. No charges were made." +paymentCanceled = "Payment was canceled. No charges were made." perMonth = "/month" preparing = "Preparing your checkout..." redirecting = "Redirecting to secure checkout..." refreshBilling = "I've Completed Payment - Refresh Billing" success = "Payment Successful!" successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly." -syncError = "Payment successful but licence sync failed. Your licence will be updated shortly. Please contact support if issues persist." -syncingLicense = "Syncing your upgraded licence..." +syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist." +syncingLicense = "Syncing your upgraded license..." upgradeComplete = "Upgrade Complete" -upgradeCompleteMessage = "Your subscription has been upgraded successfully. Your existing licence key has been updated." -upgradeSuccess = "Payment successful! Your subscription has been upgraded. The licence has been updated on your server. You will receive a confirmation email shortly." +upgradeCompleteMessage = "Your subscription has been upgraded successfully. Your existing license key has been updated." +upgradeSuccess = "Payment successful! Your subscription has been upgraded. The license has been updated on your server. You will receive a confirmation email shortly." upgradeTitle = "Upgrade to {{planName}}" yearly = "Yearly" [payment.emailStage] continue = "Continue" -description = "We'll use this to send your licence key and receipts." +description = "We'll use this to send your license key and receipts." emailLabel = "Email Address" emailPlaceholder = "your@email.com" modalTitle = "Get Started - {{planName}}" @@ -5491,9 +5237,10 @@ REMOVE_FIRST = "Remove the first page from the document." REMOVE_FIRST_AND_LAST = "Remove both the first and last pages from the document." REMOVE_LAST = "Remove the last page from the document." REVERSE_ORDER = "Flip the document so the last page becomes first and so on." -SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch booklet printing (optimised for binding on the side)." +SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." [pdfTextEditor] +tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor" conversionFailed = "Failed to convert PDF. Please try again." converting = "Converting PDF to editable format..." currentFile = "Current file: {{name}}" @@ -5501,7 +5248,6 @@ imageLabel = "Placed image" noTextOnPage = "No editable text was detected on this page." pagePreviewAlt = "Page preview" pageSummary = "Page {{number}} of {{total}}" -tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor" title = "PDF Text Editor" viewLabel = "PDF Editor" @@ -5663,9 +5409,9 @@ title = "PDF to Presentation" tags = "single page" [pdfToSinglePage] +tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster" description = "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights." submit = "Convert To Single Page" -tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster" title = "PDF To Single Page" [pdfToSinglePage.error] @@ -5716,17 +5462,28 @@ manage = "Manage" perMonth = "/month" perSeat = "/seat" popular = "Popular" +purchase = "Purchase" +selectCredits = "Select Credit Amount" selectPlan = "Select Plan" showComparison = "Compare All Features" +totalCost = "Total Cost" upgrade = "Upgrade" withServer = "+ Server Plan" +[plan.activePlan] +subtitle = "Your current subscription details" +title = "Active Plan" + [plan.api] large = "5,000 Credits" medium = "1,000 Credits" small = "500 Credits" xsmall = "100 Credits" +[plan.apiPackages] +subtitle = "Purchase API credits for your applications" +title = "API Credit Packages" + [plan.availablePlans] loadError = "Unable to load plan pricing. Using default values." subtitle = "Choose the plan that fits your needs" @@ -5739,7 +5496,7 @@ highlight3 = "Latest features" name = "Enterprise" requiresServer = "Requires Server" requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise." -siteLicense = "Site Licence" +siteLicense = "Site License" [plan.feature] api = "API Access" @@ -5778,12 +5535,6 @@ highlight3 = "Community support" included = "Included" name = "Free" -[plan.freeLimit] -cta = "View Processor Plan" -dismiss = "Maybe Later" -message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day." -title = "Woah, {{total}} PDFs Processed!" - [plan.highlights] advancedIntegrations = "Advanced integrations" allBasicFeatures = "All basic features" @@ -5815,16 +5566,10 @@ highlight2 = "Advanced PDF tools" highlight3 = "No watermarks" name = "Pro" -[plan.spendCap] -cta = "View Spending Limit" -dismiss = "Not Now" -message = "You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going." -title = "You're on a Roll!" - [plan.static] -activateLicense = "Activate Your Licence" -contactToUpgrade = "Contact us to upgrade or customise your plan" -getLicense = "Get Server Licence" +activateLicense = "Activate Your License" +contactToUpgrade = "Contact us to upgrade or customize your plan" +getLicense = "Get Server License" monthlyBilling = "Monthly Billing" selectPeriod = "Select Billing Period" upgradeToEnterprise = "Upgrade to Enterprise" @@ -5836,129 +5581,31 @@ message = "You will need to verify your email address in the Stripe billing port title = "Email Verification Required" [plan.static.licenseActivation] -activate = "Activate Licence" +activate = "Activate License" checkoutOpened = "Checkout Opened in New Tab" doLater = "I'll do this later" -enterKey = "Enter your licence key below to activate your plan:" -instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your licence key." -keyDescription = "Paste the licence key from your email" -success = "Licence Activated!" -successMessage = "Your licence has been successfully activated. You can now close this window." +enterKey = "Enter your license key below to activate your plan:" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +keyDescription = "Paste the license key from your email" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." [plan.team] name = "Team" [plan.trial] +badge = "Trial" continueWithFree = "Continue with Free" +daysRemaining = "Your trial ends in {{days}} days" +endDate = "Expires: {{date}}" expired = "Your Trial Has Ended" expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." freeTierLimitations = "Free tier includes basic PDF tools with usage limits." message = "" subscribe = "Subscribe to Pro" subscribeToPro = "Subscribe to Pro" - -[policies] -deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." -deleteConfirmTitle = "Delete {{label}} policy?" - -[policies.catalog] -compliance = "Compliance" -ingestion = "Ingestion" -retention = "Retention" -routing = "Routing" -security = "Security" - -[policies.detail] -editSettings = "Edit Settings" -enforces = "Enforces" -managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." -noActivityDescription = "Documents will appear here once this policy runs." -noActivityTitle = "No activity yet" -onEveryUpload = "On every upload" -originalsNote = "Originals stay untouched • Enforced version saved alongside" -pause = "Pause" -recentActivity = "Recent Activity" -resume = "Resume" -retry = "Retry" -showLess = "Show less" -showMore = "Show more" -statActive = "Active" -statDataProcessed = "Data processed" -statDocsEnforced = "Docs enforced" -statusActive = "Active" -statusPaused = "Paused" - -[policies.fields] -selectedCount = "{{count}} selected" - -[policies.pii] -fieldLabel = "PII to redact" -placeholder = "Select PII types" - -[policies.sidebar] -activeCount = "{{count}} active" -infoAriaLabel = "What is a policy?" -infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps." -loading = "Loading…" -railAriaLabel = "{{label}} policy — {{status}}" -railSuffixActive = " (Active)" -railSuffixPaused = " (Paused)" -setUp = "Set up" -title = "Policies" -upgradeToEnterprise = "Upgrade to enterprise" - -[policies.status] -active = "Active" -paused = "Paused" -setup = "Set up" - -[policies.toolConfig] -enableAriaLabel = "Enable {{tool}}" -infoAriaLabel = "What does {{tool}} do?" - -[policies.wizard] -allDocTypesDescription = "Enable the Classification policy to filter by document type." -allDocTypesTitle = "All document types" -back = "Back" -builderDesc = "Build the sequence of tools this policy runs on each document." -clear = "Clear" -continue = "Continue" -docTypesLabel = "Document types" -edit = "Edit" -editTitle = "Edit {{label}} Policy" -enablePolicy = "Enable Policy" -filenameAutoNumber = "Auto-number" -filenamePositionAria = "Filename position" -filenamePrefix = "Prefix" -filenameSuffix = "Suffix" -filenameTextAria = "Filename text" -filenameTextPlaceholder = "Text to add (optional)" -lockedDescription = "Contact a team leader to change this policy." -lockedTitle = "Managed by your organisation" -maxRetriesLabel = "Max retries" -noToolsError = "Add at least one configured tool to the workflow first." -outputAsLabel = "Output as" -outputFilenameSubhead = "Output filename" -outputModeAria = "Output mode" -outputNewFile = "New file" -outputNewVersion = "New version" -outputRetriesLabel = "Output & retries" -outputSubhead = "Output" -retryDelayAria = "Retry delay minutes" -retryDelayLabel = "Retry delay (min)" -runOnExport = "Export" -runOnLabel = "Run on" -runOnSubhead = "Run on" -runOnUpload = "Upload" -saveChanges = "Save Changes" -saveError = "Couldn't save the policy. Please try again." -setupClassification = "Set up Classification" -setupTitle = "Set up {{label}} Policy" -sourcesDesc = "Choose where this policy runs and which document types it applies to." -sourcesLabel = "Sources" -stepOf = "Step {{step}} of {{total}}" -toolChainDesc = "Configure the tools this policy runs on each document." -typesSelected = "{{count}} types selected" +subscriptionScheduled = "Subscription scheduled - starts {{date}}" +title = "Free Trial Active" [printFile] title = "Print File" @@ -6240,9 +5887,6 @@ whatsNewTourDesc = "Tour the updated layout" admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." -[read] -tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" - [redact] submit = "Redact" tags = "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact" @@ -6373,8 +6017,8 @@ title = "What it does" title = "About Remove Annotations" [removeBlanks] -submit = "Remove blank pages" tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank" +submit = "Remove blank pages" title = "Remove Blanks" [removeBlanks.error] @@ -6432,8 +6076,8 @@ failed = "An error occurred whilst removing certificate signatures." title = "Certificate Removal Results" [removeImage] -submit = "Remove image" tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics" +submit = "Remove image" title = "Remove image" [removeImage.error] @@ -6504,8 +6148,8 @@ title = "Decrypted PDFs" description = "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." [reorganizePages] +tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" submit = "Reorganize Pages" -tags = "rearrange,reorder,organise,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" [reorganizePages.error] failed = "Failed to reorganize pages" @@ -6530,13 +6174,13 @@ title = "Repair Results" [replace-color] submit = "Replace" -title = "Replace-Invert-Colour" +title = "Replace-Invert-Color" [replace-color.selectText] 1 = "Replace or invert colour options" -10 = "Choose text Colour" -11 = "Choose background Colour" -5 = "High contrast colour options" +10 = "Choose text Color" +11 = "Choose background Color" +5 = "High contrast color options" 6 = "White text on black background" 7 = "Black text on white background" 8 = "Yellow text on black background" @@ -6559,7 +6203,7 @@ highContrast = "High contrast" invertAll = "Invert all colours" [replaceColor.tooltip.cmyk] -text = "Convert the PDF from RGB colour space to CMYK colour space, optimised for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers." +text = "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers." title = "Convert to CMYK" [replaceColor.tooltip.custom] @@ -6588,11 +6232,11 @@ text = "Completely invert all colours in the PDF, creating a negative-like effec title = "Invert All Colours" [rotate] +tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation" rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" selectRotation = "Select Rotation Angle (Clockwise)" submit = "Apply Rotation" -tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation" title = "Rotate PDF" [rotate.error] @@ -6612,10 +6256,10 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments title = "Rotate Settings Overview" [sanitize] +tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" desc = "Remove potentially harmful elements from PDF files." sanitizationResults = "Sanitisation Results" submit = "Sanitise PDF" -tags = "clean,purge,remove,sanitise,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" title = "Sanitise" [sanitize.error] @@ -6662,9 +6306,6 @@ title = "Sanitise PDF" tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size" title = "Adjust page-scale" -[scannerEffect] -tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" - [ScannerImageSplit.selectText] 1 = "Angle Threshold:" 10 = "Extra padding (in pixels) around each saved photo so edges aren't cut." @@ -6679,7 +6320,7 @@ tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look sca [scannerImageSplit] submit = "Extract Image Scans" -tags = "separate,auto-detect,scans,multi-photo,organise" +tags = "separate,auto-detect,scans,multi-photo,organize" title = "Extracted Images" [scannerImageSplit.error] @@ -6730,18 +6371,18 @@ header = "Managing Signing Sessions" [sessionManagement.tooltip.addParticipants] bullet1 = "New participants added to the end of signing order" -bullet2 = "Cannot add participants after session is finalised" +bullet2 = "Cannot add participants after session is finalized" bullet3 = "Each participant receives a notification when it's their turn" -description = "You can add more participants to an active session at any time before finalisation." +description = "You can add more participants to an active session at any time before finalization." title = "Adding Participants" [sessionManagement.tooltip.finalization] -bullet1 = "Full finalisation: All participants have signed" -bullet2 = "Partial finalisation: Some participants haven't signed yet" +bullet1 = "Full finalization: All participants have signed" +bullet2 = "Partial finalization: Some participants haven't signed yet" bullet3 = "Unsigned participants will be excluded from the final document" -bullet4 = "Once finalised, you can load the signed PDF into active files" -description = "Finalisation combines all signatures into a single signed PDF. This action cannot be undone." -title = "Session Finalisation" +bullet4 = "Once finalized, you can load the signed PDF into active files" +description = "Finalization combines all signatures into a single signed PDF. This action cannot be undone." +title = "Session Finalization" [sessionManagement.tooltip.participantRemoval] bullet1 = "Cannot remove participants who have already signed" @@ -6851,12 +6492,12 @@ modeDisabled = "Don't check for updates" modePrompt = "Ask me before installing updates" serverNeedsUpdate = "Server needs to be updated by administrator" title = "Software Updates" -updateBehavior = "Update behaviour" +updateBehavior = "Update behavior" updateBehaviorDescription = "Choose whether to prompt before installing updates, install them automatically, or skip update checks entirely." -updateBehaviorError = "Could not change update behaviour" +updateBehaviorError = "Could not change update behavior" updateBehaviorErrorLocked = "This setting is locked by your administrator." updateBehaviorLockedDescription = "Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this." -updateBehaviorSaved = "Update behaviour saved." +updateBehaviorSaved = "Update behavior saved." versionMismatch = "Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version." viewDetails = "View Details" @@ -6890,7 +6531,7 @@ capturing = "Press keys… (Esc to cancel)" change = "Change shortcut" customBadge = "Custom" defaultLabel = "Default: {{shortcut}}" -description = "Customise keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel." +description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel." errorConflict = "Shortcut already used by {{tool}}." none = "Not assigned" noShortcut = "No shortcut set" @@ -6903,18 +6544,6 @@ title = "Keyboard Shortcuts" mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut." windows = "Include Ctrl, Alt, or another modifier in your shortcut." -[settings.legal] -label = "Legal" -title = "Legal" - -[settings.legal.cookiePreferences] -description = "Review or change your cookie consent choices." -manage = "Manage" - -[settings.legal.documents] -description = "Policies and legal information for this service." -title = "Legal Documents" - [settings.licensingAnalytics] audit = "Audit" plan = "Plan" @@ -6947,7 +6576,7 @@ retry = "Retry" [settings.planBilling.status] active = "Active" -canceled = "Cancelled" +canceled = "Canceled" pastDue = "Past Due" trial = "Trial" @@ -7026,7 +6655,7 @@ title = "Team" [settings.tooltips] enableLoginFirst = "Enable login mode first" -requiresEnterprise = "Requires Enterprise licence" +requiresEnterprise = "Requires Enterprise license" [settings.workspace] people = "People" @@ -7101,6 +6730,7 @@ title = "Show Javascript" title = "Extracted JavaScript" [sign] +tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" activate = "Activate Signature Placement" add = "Add" addToAll = "Add to all pages" @@ -7120,7 +6750,6 @@ redo = "Redo" save = "Save Signature" sharedSigs = "Shared Signatures" submit = "Sign Document" -tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorise,endorse,ink signature,handwriting" title = "Sign" undo = "Undo" updateAndPlace = "Update and Place" @@ -7308,11 +6937,11 @@ small = "Small" x-large = "X-Large" [split] +tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter" resultsTitle = "Split Results" selectMethod = "Select a split method" splitPages = "Enter pages to split on:" submit = "Split" -tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter" title = "Split PDF" [split.error] @@ -7363,7 +6992,7 @@ bullet4 = "You can change methods at any time before processing" title = "Choose Your Split Method" [split.methodSelection.tooltip.header] -text = "Choose how you want to split your PDF document. Each method is optimised for different use cases and document types." +text = "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types." title = "Split Method Selection" [split.poster] @@ -7461,7 +7090,7 @@ placeholder = "e.g. 5, 10" [split-by-sections] merge = "Merge Into One PDF" -tags = "Section Split, Divide, Customise,Customise" +tags = "Section Split, Divide, Customize,Customise" title = "Split PDF by Sections" [split-by-sections.customPages] @@ -7499,7 +7128,7 @@ includeMetadata = "Include Metadata" title = "Split PDF by Chapters" [splitPdfByChapters] -tags = "split,chapters,bookmarks,organise" +tags = "split,chapters,bookmarks,organize" [storageShare] accessDenied = "You do not have access to this shared file. Ask the owner to share it with you." @@ -7602,6 +7231,10 @@ title = "Upload to Server" updateButton = "Update on Server" uploadButton = "Upload to Server" +[survey] +nav = "Survey" +title = "Stirling-PDF Survey" + [swagger] tags = "api,documentation,swagger,endpoints,development" title = "API Documentation" @@ -7632,6 +7265,28 @@ removeError = "Failed to remove member" renameError = "Failed to rename team" renameSuccess = "Team renamed successfully" +[team.features] +badge = "Team Features" +subtitle = "Collaborate with your team" +title = "Team Collaboration" +viewPlans = "View Plans" + +[team.features.billing] +description = "Manage billing and subscriptions" +title = "Billing Management" + +[team.features.credits] +description = "Shared credit pool for all members" +title = "Shared Credits" + +[team.features.dashboard] +description = "View team activity and usage" +title = "Team Dashboard" + +[team.features.invite] +description = "Add members to your team" +title = "Invite Members" + [team.invitationBanner] acceptButton = "Accept" message = "has invited you to join" @@ -7653,8 +7308,13 @@ remove = "Remove" roleColumn = "Role" title = "Team Members" +[team.upgrade] +button = "Upgrade to Team" +description = "Unlock team collaboration features" +title = "Upgrade to Team Plan" + [textAlign] -center = "Centre" +center = "Center" left = "Left" right = "Right" @@ -7668,10 +7328,10 @@ justNow = "just now" minutesAgo = "{{count}}m ago" [timestampPdf] +tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise" desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server." results = "Timestamp Results" submit = "Apply Timestamp" -tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise" title = "Timestamp PDF" [timestampPdf.error] @@ -7824,7 +7484,7 @@ urgent = "Urgent" [upgradeBanner] attentionBody = "Your admin needs to sign in to see more info. Please contact them immediately." -attentionBodyAdmin = "Review the licence requirements to keep this server compliant." +attentionBodyAdmin = "Review the license requirements to keep this server compliant." attentionTitle = "This server needs admin attention" message = "Get the most out of Stirling PDF with unlimited users and advanced features" seeInfo = "See info" @@ -8067,173 +7727,10 @@ title = "View/Edit PDF" [warning] tooltipTitle = "Warning" -[watchedFolders] -alreadyInFolder = "Already in this folder" -defaultFolderWarning = "This is a default folder and will be recreated on next reload." -deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." -deleteConfirmTitle = "Delete folder?" -folderNotFound = "Folder not found" -newFolder = "New folder" -noFolders = "No watched folders yet" -sidebarTitle = "Watched Folders" -title = "Watched Folders" - -[watchedFolders.actions] -back = "Back" -collapse = "Collapse" -dismiss = "Dismiss" -download = "Download" -downloadInput = "Download input" -downloadOutput = "Download output" -expand = "Expand" -retry = "Retry" - -[watchedFolders.card] -delete = "Delete folder" -edit = "Edit folder" - -[watchedFolders.home] -addAnother = "Add another Watched Folder" -addAnotherDesc = "Automatically process files with a new pipeline" -create = "Create your first Watched Folder" -deleteFolder = "Delete folder" -editFolder = "Edit folder" -empty = "No watched folders yet" -emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." -emptyTitle = "Automate your PDF workflows" -file = "file" -files = "files" -pause = "Pause" -resume = "Resume" - -[watchedFolders.howItWorks] -step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" -step1Title = "Drop files" -step2Desc = "Your configured tools process each file automatically" -step2Title = "Pipeline runs" -step3Desc = "Download processed files from inside the folder" -step3Title = "Output ready" -title = "How Watched Folders work" - -[watchedFolders.modal] -advanced = "Advanced" -automation = "Automation" -automationNameFallback = "Watched Folder automation" -automationRequired = "Add at least one configured step before saving." -autoNumber = "Auto-number" -autoNumberExample = "e.g. document.pdf → document (1).pdf" -autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." -changeFolder = "Change" -chooseFolder = "Choose" -clearFolder = "Clear" -color = "Accent colour" -createFolder = "Create Folder" -createTitle = "New Watched Folder" -editTitle = "Edit Watched Folder" -inputFolder = "Input folder" -inputFolderNotChosen = "No folder chosen — required for auto-scan" -inputSource = "Input source" -inputSourceBrowser = "Browser — drop files here" -inputSourceLocal = "Local folder (auto-scan)" -inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" -localOutputFolder = "Local output folder" -maxRetries = "Max retries" -name = "Folder name" -namePlaceholder = "My Watched Folder" -nameRequired = "Folder name is required" -nameTooLong = "Folder name must be 50 characters or less" -outputFolderNotSet = "Not set — outputs stay in app" -outputFolderUnsupported = "Not supported in this browser" -outputModeNewDesc = "Output is saved as a new separate file" -outputModeVersion = "Replace original?" -outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" -outputName = "Output filename prefix" -outputNamePrefix = "Filename prefix" -outputNameSuffix = "Suffix" -positionPrefix = "Prefix" -positionSuffix = "Suffix" -replaceOriginal = "Replace original file" -retryDelay = "Delay (minutes)" -saveChanges = "Save Changes" -saveFailed = "Failed to save folder. Please try again." -sectionFolder = "Folder" -sectionSourceOutput = "Source & Output" -sectionSteps = "Steps" - -[watchedFolders.status] -active = "Active" -done = "Done" -paused = "Paused" -processing = "Processing" - -[watchedFolders.time] -daysAgo = "{{count}}d ago" -hoursAgo = "{{count}}h ago" -justNow = "just now" -minutesAgo = "{{count}}m ago" - -[watchedFolders.workbench] -activity = "Activity" -addFiles = "Add files" -allTime = "All time" -cancel = "Cancel" -chartTooltipComplete = "{{count}} complete" -chartTooltipFailed = "{{count}} failed" -clearSelection = "Clear selection" -countProcessing = "{{count}} processing" -countQueued = "{{count}} queued" -countSelected = "{{count}} selected" -dataSaved = "Saved" -dayRunning = "day running" -daysRunning = "days running" -delete = "Delete" -deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." -deleteOutputs = "Delete outputs" -directionIn = "in" -directionOut = "out" -dropToProcess = "Drop to process" -export = "Export" -exportSeparately = "Export separately" -exportZip = "Export zip" -failed = "Failed" -filesProcessedOverTime = "Files processed over time" -filterAll = "All" -filterPending = "Pending" -inputs = "Inputs" -inputSize = "Input size" -legendComplete = "Complete" -noActivity = "No activity yet — drop a PDF to start" -noActivityMatch = "No matching activity" -noInputFolder = "No input folder — edit to configure" -outputs = "Outputs" -pause = "Pause" -preview = "Preview" -previewInput = "Preview input" -previewLoadFailed = "Could not load file preview." -previewOutput = "Preview output" -queued = "Queued" -queuedWaiting = "Queued — waiting to run" -removeEntries = "Remove {{count}} entries" -removeEntry = "Remove entry" -removeNotificationsOnly = "Remove notifications only" -resume = "Resume" -retryAll = "Retry all" -retryIn = "retry in {{count}}m" -retryingSoon = "retrying…" -retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" -running = "Running" -search = "Search…" -selectAll = "Select all {{count}}" -sortNameAsc = "A → Z" -sortNameDesc = "Z → A" -sortNewest = "Newest" -sortOldest = "Oldest" -watching = "Watching: {{name}}" - [watermark] +tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text" desc = "Add text or image watermarks to PDF files" submit = "Add Watermark" -tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text" title = "Add Watermark" [watermark.error] @@ -8373,7 +7870,7 @@ text = "Text" header = "Signature Creation Methods" [wetSignature.tooltip.draw] -bullet1 = "Customise pen colour and thickness" +bullet1 = "Customize pen color and thickness" bullet2 = "Clear and redraw until satisfied" bullet3 = "Works on touch devices (tablets, phones)" description = "Create a handwritten signature using your mouse or touchscreen. Best for personal, authentic signatures." @@ -8381,8 +7878,8 @@ title = "Draw Signature" [wetSignature.tooltip.type] bullet1 = "Choose from multiple fonts" -bullet2 = "Customise text size and colour" -bullet3 = "Perfect for standardised signatures" +bullet2 = "Customize text size and color" +bullet3 = "Perfect for standardized signatures" description = "Generate a signature from typed text. Fast and consistent, suitable for business documents." title = "Type Signature" @@ -8593,7 +8090,7 @@ username = "Username" [workspace.people.license] currentUsage = "Currently using {{current}} of {{max}} user licences" -fromLicense = "from licence" +fromLicense = "from license" grandfathered = "Grandfathered" grandfatheredShort = "{{count}} grandfathered" noSlotsAvailable = "No slots available" @@ -8624,7 +8121,7 @@ confirmDelete = "Are you sure you want to delete this team? This team must be em confirmRemove = "Remove user from this team?" createNewTeam = "Create New Team" deleteTeamLabel = "Delete Team" -description = "Manage teams and organise workspace members" +description = "Manage teams and organize workspace members" loadError = "Failed to load team details" loading = "Loading teams..." loadingDetails = "Loading team details..." @@ -8694,3 +8191,189 @@ cancel = "Cancel" confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" + +[watchedFolders] +alreadyInFolder = "Already in this folder" +deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." +deleteConfirmTitle = "Delete folder?" +defaultFolderWarning = "This is a default folder and will be recreated on next reload." +folderNotFound = "Folder not found" +newFolder = "New folder" +noFolders = "No watched folders yet" +sidebarTitle = "Watched Folders" +title = "Watched Folders" + +[watchedFolders.modal] +automation = "Automation" +createFolder = "Create Folder" +automationRequired = "Add at least one configured step before saving." +color = "Accent color" +createTitle = "New Watched Folder" +editTitle = "Edit Watched Folder" +name = "Folder name" +namePlaceholder = "My Watched Folder" +nameRequired = "Folder name is required" +nameTooLong = "Folder name must be 50 characters or less" +saveChanges = "Save Changes" +saveFailed = "Failed to save folder. Please try again." +automationNameFallback = "Watched Folder automation" +maxRetries = "Max retries" +retryDelay = "Delay (minutes)" +outputModeVersion = "Replace original?" +outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" +outputModeNewDesc = "Output is saved as a new separate file" +outputName = "Output filename prefix" +outputNameSuffix = "Suffix" +sectionFolder = "Folder" +sectionSourceOutput = "Source & Output" +sectionSteps = "Steps" +inputSource = "Input source" +inputSourceBrowser = "Browser — drop files here" +inputSourceLocal = "Local folder (auto-scan)" +inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" +inputFolder = "Input folder" +inputFolderNotChosen = "No folder chosen — required for auto-scan" +changeFolder = "Change" +chooseFolder = "Choose" +clearFolder = "Clear" +autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." +localOutputFolder = "Local output folder" +outputFolderUnsupported = "Not supported in this browser" +outputFolderNotSet = "Not set — outputs stay in app" +advanced = "Advanced" +replaceOriginal = "Replace original file" +autoNumber = "Auto-number" +autoNumberExample = "e.g. document.pdf → document (1).pdf" +outputNamePrefix = "Filename prefix" +positionPrefix = "Prefix" +positionSuffix = "Suffix" + +[watchedFolders.home] +create = "Create your first Watched Folder" +editFolder = "Edit folder" +empty = "No watched folders yet" +file = "file" +files = "files" +emptyTitle = "Automate your PDF workflows" +emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." +addAnother = "Add another Watched Folder" +addAnotherDesc = "Automatically process files with a new pipeline" +resume = "Resume" +pause = "Pause" +deleteFolder = "Delete folder" + +[watchedFolders.card] +edit = "Edit folder" +delete = "Delete folder" + +[watchedFolders.status] +done = "Done" +processing = "Processing" +paused = "Paused" +active = "Active" + +[watchedFolders.howItWorks] +title = "How Watched Folders work" +step1Title = "Drop files" +step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" +step2Title = "Pipeline runs" +step2Desc = "Your configured tools process each file automatically" +step3Title = "Output ready" +step3Desc = "Download processed files from inside the folder" + +[watchedFolders.workbench] +addFiles = "Add files" +inputs = "Inputs" +outputs = "Outputs" +failed = "Failed" +dataSaved = "Saved" +running = "Running" +dropToProcess = "Drop to process" +activity = "Activity" +noActivity = "No activity yet — drop a PDF to start" +noActivityMatch = "No matching activity" +retryAll = "Retry all" +retryIn = "retry in {{count}}m" +retryingSoon = "retrying…" +pause = "Pause" +resume = "Resume" +dayRunning = "day running" +daysRunning = "days running" +sortNewest = "Newest" +sortOldest = "Oldest" +sortNameAsc = "A → Z" +sortNameDesc = "Z → A" +filterAll = "All" +filterPending = "Pending" +search = "Search…" +retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" +watching = "Watching: {{name}}" +noInputFolder = "No input folder — edit to configure" +countProcessing = "{{count}} processing" +countQueued = "{{count}} queued" +exportZip = "Export zip" +exportSeparately = "Export separately" +countSelected = "{{count}} selected" +selectAll = "Select all {{count}}" +delete = "Delete" +clearSelection = "Clear selection" +preview = "Preview" +export = "Export" +previewInput = "Preview input" +previewOutput = "Preview output" +directionIn = "in" +directionOut = "out" +queuedWaiting = "Queued — waiting to run" +allTime = "All time" +queued = "Queued" +inputSize = "Input size" +filesProcessedOverTime = "Files processed over time" +legendComplete = "Complete" +chartTooltipComplete = "{{count}} complete" +chartTooltipFailed = "{{count}} failed" +removeEntry = "Remove entry" +removeEntries = "Remove {{count}} entries" +deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." +cancel = "Cancel" +removeNotificationsOnly = "Remove notifications only" +deleteOutputs = "Delete outputs" +previewLoadFailed = "Could not load file preview." + +[watchedFolders.actions] +back = "Back" +dismiss = "Dismiss" +retry = "Retry" +download = "Download" +downloadInput = "Download input" +downloadOutput = "Download output" +collapse = "Collapse" +expand = "Expand" + +[watchedFolders.time] +justNow = "just now" +minutesAgo = "{{count}}m ago" +hoursAgo = "{{count}}h ago" +daysAgo = "{{count}}d ago" +[addStamp] +tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" + +[annotate] +tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" + +[devAirgapped] +tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone" + +[devApi] +tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting" + +[devFolderScanning] +tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring" + +[devSsoGuide] +tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP" + +[read] +tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" + +[scannerEffect] +tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5b22c3064..373a253b0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -50,9 +50,12 @@ help = "Help" imgPrompt = "Select Image(s)" incorrectPasswordMessage = "Current password is incorrect." info = "Info" +insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." +loadingCredits = "Checking credits..." +loadingProStatus = "Checking subscription status..." logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" @@ -63,6 +66,7 @@ noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" nothingToUndo = "Nothing to undo" +noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" openInNewWindow = "Open in new window" @@ -1130,10 +1134,6 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint." title = "MCP Server" -[admin.settings.mcp.acceptedAudiences] -description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated." -label = "Additional accepted audiences (optional)" - [admin.settings.mcp.allowedOps] description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below." label = "Allowed tools" @@ -1575,7 +1575,30 @@ admin = "Admin" title = "User Control Settings" [agents] +auto_redaction_description = "Redact PII automatically" +auto_redaction_name = "Auto Redaction" +back_to_tools = "Back to tools" +coming_soon = "Coming soon" +compliance_description = "Audit documents for compliance" +compliance_name = "Compliance Check" +data_extraction_description = "Extract tables & structured data" +data_extraction_name = "Data Extraction" +doc_summary_description = "Summarize long documents" +doc_summary_name = "Summarizer" +form_filler_description = "Fill PDF forms intelligently" +form_filler_name = "Form Filler" +pdf_to_markdown_description = "Convert PDFs to clean Markdown" +pdf_to_markdown_name = "PDF to Markdown" +section_title = "Agents" +show_less = "Show less" +start_chat = "Start chatting" +stirling_description = "Your general-purpose PDF assistant" +stirling_full_name = "Stirling General Agent" +stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents." stirling_name = "Stirling" +stirling_running = "Running..." +stirling_tooltip = "Stirling agent" +view_all = "View all agents" [analytics] learnMore = "Learn more about our analytics" @@ -2655,6 +2678,9 @@ title = "Change Permissions" [changePermissions.tooltip.warning] text = "To make these permissions unchangeable, use the Add Password tool to set an owner password." +[chat] +resize = "Resize chat panel" + [chat.actions] copy = "Copy message" @@ -2669,7 +2695,6 @@ clearChat = "Clear chat" [chat.input] placeholder = "What do you want to do?" send = "Send message" -disclaimer = "AI can make mistakes. Be sure to verify the output before sharing." [chat.progress] analyzing = "Analyzing your request..." @@ -2704,6 +2729,8 @@ mergeMany = "Merge these {{count}} documents into 1" moreFiles = "+{{count}} more" openFromComputer = "Open from computer" removeFile = "Remove {{name}}" +rotateMany = "Rotate these documents" +rotateOne = "Rotate this document" splitOne = "Split this document" [chat.responses] @@ -2714,7 +2741,6 @@ need_clarification = "Could you clarify your request?" not_found = "I couldn't find the requested information." processing = "Processing ({{outcome}})..." unsupported_capability = "Unsupported capability: {{capability}}" -usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going." [chat.toolsUsed] summary = "Ran {{count}} tools" @@ -2741,6 +2767,7 @@ copy = "Copy" done = "Done" error = "Error" expand = "Expand" +learnMore = "Learn more" loading = "Loading..." next = "Next" operation = "this operation" @@ -2939,7 +2966,6 @@ tags = "squish,small,tiny" [config] plan = "Plan" -team = "Team" [config.account.overview] confirmDelete = "Delete My Account" @@ -3035,7 +3061,6 @@ warning = "⚠️ Warning: This action will generate new API keys and make your [config.mcp] description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf." -guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf." navLabel = "MCP Server" tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly." title = "MCP Server" @@ -3057,6 +3082,14 @@ addTo = "Add to" hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy." title = "Connect your AI assistant" +[config.mcp.tools] +ai = "AI" +convert = "Convert" +misc = "Misc" +pages = "Pages" +security = "Security" +title = "What your assistant can do" + [config.overview] description = "Current application settings and configuration details." error = "Error" @@ -3754,7 +3787,6 @@ selectFile = "Select file {{name}}" shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." shareManage = "Manage sharing" showDetails = "Show details" -signInRequired = "Sign in to use cloud storage." summary = "{{count}} items" tree = "Folders" upload = "Upload" @@ -4718,34 +4750,6 @@ title = "Authentication Failed" message = "You can close this window and return to Stirling PDF." title = "Authentication Successful" -[oauthConsent] -approve = "Allow access" -approving = "Allowing..." -decisionFailed = "Could not submit your decision. Please try again." -deny = "Deny" -denying = "Denying..." -loadFailed = "This authorization request is invalid or has expired. Close this tab and try connecting again from the app." -loading = "Loading authorization request..." -missingId = "Missing authorization request. Start the connection from your app and try again." -redirecting = "Returning you to the app..." -requesting = "{{app}} wants to access your Stirling PDF account" -scopesIntro = "This will allow {{app}} to:" -signedInAs = "Signed in as {{name}}" -signInButton = "Sign in to continue" -signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app." -title = "Authorize access" -unknownApp = "A third-party application" - -[oauthConsent.access] -actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage" -tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents" - -[oauthConsent.scope] -email = "See your email address" -openid = "Confirm your identity" -phone = "See your phone number" -profile = "See your basic profile information" - [ocr] desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text." tags = "recognition,text,image,scan,read,identify,detection,editable" @@ -4863,6 +4867,15 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc title = "Download" titleWithOs = "Download for {{osLabel}}" +[onboarding.freeTrial] +afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access." +afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends." +body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing." +daysRemaining = "{{days}} days remaining" +daysRemainingSingular = "{{days}} day remaining" +title = "Your 30-Day Pro Trial" +trialEnds = "Trial ends {{date}}" + [onboarding.mfa] authenticationCode = "Authentication code" qrCodeLoading = "Generating your QR code…" @@ -4874,22 +4887,6 @@ adminTitle = "Admin Overview" userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" -[onboarding.saas.freeEditor] -freeLine = "The editor is now completely free." -premium = "We've added loads of new features, including Policies and Agent Chat." -title = "Welcome to Stirling" - -[onboarding.saas.team] -addButton = "Add" -createBody = "Work on documents together: teammates share files, automations and your plan. Add the first member by email to create your team." -createTitle = "Create your team" -inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email and they'll get an invite." -inviteTitle = "Invite members to your team" - -[onboarding.saas.usage] -body = "Automations, AI and API requests draw from your free allowance. Manual editing never counts against it." -title = "Your free Processor allowance" - [onboarding.securityCheck] message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." roleAdmin = "Admin" @@ -5160,208 +5157,6 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" -[payg.activity] -docs = "docs" -empty = "No billable activity yet this period." -subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." -title = "Recent billable activity" - -[payg.cap] -amount = "Cap amount" -custom = "Custom" -docsEstimate = "≈ {{docs}} processed PDFs / month" -docsRate = "at {{rate}} / PDF" -noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." -noCapLabel = "No cap" -noneShort = "No cap" -perMonth = "/ month" -save = "Update cap" -subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." -title = "Monthly spending cap" - -[payg.checkout] -connecting = "Connecting to Stripe…" -errorTitle = "Stripe error" - -[payg.checkout.error] -startFailed = "Couldn't start checkout session" - -[payg.checkout.mock] -backend = "Backend is in mock mode; no real Stripe session was created." -continue = "Continue with mock subscription" -noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured." -title = "Stripe Embedded Checkout (mock mode)" - -[payg.confirm] -body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs." -capValue = "{{symbol}}{{amount}} / month" -noCap = "No cap" -note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page." -summaryLabel = "Monthly ceiling" -title = "Welcome to the Processor plan" - -[payg.docHelp] -billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free." -chains = "Running the same file through several steps of one automation counts it once, not once per step." -perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one." -refunds = "If a job fails on its first step, the PDF is credited back automatically." -toggle = "What counts as a PDF?" - -[payg.error] -body = "We couldn't reach the billing service. Refresh the page to try again." -title = "Couldn't load your plan" - -[payg.free.cta] -benefit1Body = "chain tools, schedule runs, batch process" -benefit1Title = "Automation pipelines" -benefit2Body = "summarize, classify, redact, AI-OCR" -benefit2Title = "AI tools" -benefit3Body = "call any Stirling endpoint programmatically" -benefit3Title = "API access" -button = "Turn on Processor →" -reassurance = "No minimum · Set a $0 cap to test · Cancel anytime" -subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control." -title = "Turn on the Processor plan" - -[payg.free.editor] -eyebrow = "Editor plan · Always free" - -[payg.free.header] -freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." -freeTitle = "Unlimited PDF editing" - -[payg.free.hero] -capSuffix = "/ {{limit}} free PDFs" -metaCategories = "Automation · AI · API requests" - -[payg.free.member] -ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." - -[payg.free.proc] -eyebrow = "Processor plan · metered" - -[payg.free.state] -approachingLimit = "Approaching limit" -limitReached = "Limit reached" -plentyLeft = "Plenty left" - -[payg.gates] -ai = "AI tools (AI Create, suggestions, AI-OCR)" -automation = "Automations & pipelines" -client = "Browser-only tools (viewer, page editor, file management)" -offsite = "Server tools (compress, OCR, convert, watermark…)" -pauses = "pauses at cap" -title = "What happens when the cap is reached" - -[payg.header] -eyebrow = "Processor plan · {{start}} – {{end}}" -freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." -freeLabel = "Always free" -freeTitle = "Unlimited PDF editing" -meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap." -meterLabel = "Metered" -meterTitle = "Automation · AI · API" - -[payg.member] -askLeader = "Only your team owner can change the cap." - -[payg.members] -docs = "PDFs" -subtitle = "Billable PDFs each teammate has processed this period." -title = "Team member usage" - -[payg.role] -leader = "Team owner" -member = "Member" - -[payg.signupRequired] -body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing." -cancel = "Not now" -cta = "Sign up free" -subtext = "Creating an account is free and takes a few seconds. No credit card required." -title = "Sign up to use {{category}}" - -[payg.signupRequired.category] -ai = "AI features" -api = "this tool" -automation = "automations" -default = "this feature" - -[payg.spendCapMeter] -capSuffix = "/ {{amount}} cap" -metaCategories = "Automation · AI · API spend" -resets = "Resets each billing period" - -[payg.state] -degraded = "Cap reached" -full = "Healthy" -warned = "Approaching cap" - -[payg.stripe] -open = "Open billing portal" -subtitle = "Receipts, invoices, payment method, billing currency." -title = "Manage billing in Stripe" - -[payg.stripe.toast.unavailable] -body = "Billing portal isn't available right now. Try again in a moment." -title = "Billing portal unavailable" - -[payg.upgrade] -backAria = "Back" -closeAria = "Close" - -[payg.upgrade.button] -cancel = "Cancel" -continue = "Continue →" -finish = "Finish" - -[payg.upgrade.cap] -help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." -title = "Set your monthly spend ceiling" -usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." - -[payg.upgrade.checkout] -capValue = "{{symbol}}{{amount}} / month" -edit = "Edit" -help = "Stripe handles your card details. Stirling never sees them." -loading = "Loading checkout…" -noCap = "No cap" -title = "Add your payment method" - -[payg.upgrade.help] -aiBody = "summarize, classify, redact, AI-OCR" -aiTitle = "AI tools" -apiBody = "programmatic access to any Stirling endpoint" -apiTitle = "API calls" -automationBody = "chained tools or scheduled runs that don't need clicks" -automationTitle = "Automation pipelines" -footnote = "Manual tools (viewing, editing, merging, splitting, watermarking, compressing, manual OCR) are always free, even past 500. The distinction is the type of work, not where you click." -title = "What we count toward billing" - -[payg.upgrade.promise] -body = "You only pay for automation pipelines, AI tools, and API calls, the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge." -highlight = "Manual tools stay free, always." - -[payg.upgrade.steps] -cap = "Set monthly ceiling" -payment = "Add payment method" - -[payg.upgrade.title] -confirm = "You're subscribed" -default = "Upgrade to Processor plan" - -[payg.usage] -breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}" -capLine = "{{cap}}/mo cap" -estBill = "≈ {{amount}} so far this period" -firstFree = "First {{free}} free" -noCap = "No monthly cap" -ofLimitProcessed = "/ {{limit}} PDFs processed" -processed = "PDFs processed" -resetsIn = "Resets in {{days}} days" -resetsTomorrow = "Resets tomorrow" -thisPeriod = "This billing period" - [payment] autoClose = "This window will close automatically..." canCloseWindow = "You can now close this window." @@ -5685,17 +5480,28 @@ manage = "Manage" perMonth = "/month" perSeat = "/seat" popular = "Popular" +purchase = "Purchase" +selectCredits = "Select Credit Amount" selectPlan = "Select Plan" showComparison = "Compare All Features" +totalCost = "Total Cost" upgrade = "Upgrade" withServer = "+ Server Plan" +[plan.activePlan] +subtitle = "Your current subscription details" +title = "Active Plan" + [plan.api] large = "5,000 Credits" medium = "1,000 Credits" small = "500 Credits" xsmall = "100 Credits" +[plan.apiPackages] +subtitle = "Purchase API credits for your applications" +title = "API Credit Packages" + [plan.availablePlans] loadError = "Unable to load plan pricing. Using default values." subtitle = "Choose the plan that fits your needs" @@ -5747,12 +5553,6 @@ highlight3 = "Community support" included = "Included" name = "Free" -[plan.freeLimit] -cta = "View Processor Plan" -dismiss = "Maybe Later" -message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day." -title = "Woah, {{total}} PDFs Processed!" - [plan.highlights] advancedIntegrations = "Advanced integrations" allBasicFeatures = "All basic features" @@ -5784,12 +5584,6 @@ highlight2 = "Advanced PDF tools" highlight3 = "No watermarks" name = "Pro" -[plan.spendCap] -cta = "View Spending Limit" -dismiss = "Not Now" -message = "You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going." -title = "You're on a Roll!" - [plan.static] activateLicense = "Activate Your License" contactToUpgrade = "Contact us to upgrade or customize your plan" @@ -5818,115 +5612,18 @@ successMessage = "Your license has been successfully activated. You can now clos name = "Team" [plan.trial] +badge = "Trial" continueWithFree = "Continue with Free" +daysRemaining = "Your trial ends in {{days}} days" +endDate = "Expires: {{date}}" expired = "Your Trial Has Ended" expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." freeTierLimitations = "Free tier includes basic PDF tools with usage limits." +message = "" subscribe = "Subscribe to Pro" subscribeToPro = "Subscribe to Pro" - -[policies] -deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." -deleteConfirmTitle = "Delete {{label}} policy?" - -[policies.catalog] -compliance = "Compliance" -ingestion = "Ingestion" -retention = "Retention" -routing = "Routing" -security = "Security" - -[policies.detail] -editSettings = "Edit Settings" -enforces = "Enforces" -managedByOrg = "Managed by your organization. Contact a team leader to change this policy." -noActivityDescription = "Documents will appear here once this policy runs." -noActivityTitle = "No activity yet" -onEveryUpload = "On every upload" -originalsNote = "Originals stay untouched • Enforced version saved alongside" -pause = "Pause" -recentActivity = "Recent Activity" -resume = "Resume" -retry = "Retry" -showLess = "Show less" -showMore = "Show more" -statActive = "Active" -statDataProcessed = "Data processed" -statDocsEnforced = "Docs enforced" -statusActive = "Active" -statusPaused = "Paused" - -[policies.fields] -selectedCount = "{{count}} selected" - -[policies.pii] -fieldLabel = "PII to redact" -placeholder = "Select PII types" - -[policies.sidebar] -activeCount = "{{count}} active" -infoAriaLabel = "What is a policy?" -infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps." -loading = "Loading…" -railAriaLabel = "{{label}} policy — {{status}}" -railSuffixActive = " (Active)" -railSuffixPaused = " (Paused)" -setUp = "Set up" -title = "Policies" -upgradeToEnterprise = "Upgrade to enterprise" - -[policies.status] -active = "Active" -paused = "Paused" -setup = "Set up" - -[policies.toolConfig] -enableAriaLabel = "Enable {{tool}}" -infoAriaLabel = "What does {{tool}} do?" - -[policies.wizard] -allDocTypesDescription = "Enable the Classification policy to filter by document type." -allDocTypesTitle = "All document types" -back = "Back" -builderDesc = "Build the sequence of tools this policy runs on each document." -clear = "Clear" -continue = "Continue" -docTypesLabel = "Document types" -edit = "Edit" -editTitle = "Edit {{label}} Policy" -enablePolicy = "Enable Policy" -filenameAutoNumber = "Auto-number" -filenamePositionAria = "Filename position" -filenamePrefix = "Prefix" -filenameSuffix = "Suffix" -filenameTextAria = "Filename text" -filenameTextPlaceholder = "Text to add (optional)" -lockedDescription = "Contact a team leader to change this policy." -lockedTitle = "Managed by your organization" -maxRetriesLabel = "Max retries" -noToolsError = "Add at least one configured tool to the workflow first." -outputAsLabel = "Output as" -outputFilenameSubhead = "Output filename" -outputModeAria = "Output mode" -outputNewFile = "New file" -outputNewVersion = "New version" -outputRetriesLabel = "Output & retries" -outputSubhead = "Output" -retryDelayAria = "Retry delay minutes" -retryDelayLabel = "Retry delay (min)" -runOnExport = "Export" -runOnLabel = "Run on" -runOnSubhead = "Run on" -runOnUpload = "Upload" -saveChanges = "Save Changes" -saveError = "Couldn't save the policy. Please try again." -setupClassification = "Set up Classification" -setupTitle = "Set up {{label}} Policy" -sourcesDesc = "Choose where this policy runs and which document types it applies to." -sourcesLabel = "Sources" -stepOf = "Step {{step}} of {{total}}" -toolChainDesc = "Configure the tools this policy runs on each document." -typesSelected = "{{count}} types selected" +subscriptionScheduled = "Subscription scheduled - starts {{date}}" +title = "Free Trial Active" [printFile] title = "Print File" @@ -6871,18 +6568,6 @@ title = "Keyboard Shortcuts" mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut." windows = "Include Ctrl, Alt, or another modifier in your shortcut." -[settings.legal] -label = "Legal" -title = "Legal" - -[settings.legal.cookiePreferences] -description = "Review or change your cookie consent choices." -manage = "Manage" - -[settings.legal.documents] -description = "Policies and legal information for this service." -title = "Legal Documents" - [settings.licensingAnalytics] audit = "Audit" plan = "Plan" @@ -7571,6 +7256,7 @@ updateButton = "Update on Server" uploadButton = "Upload to Server" [survey] +nav = "Survey" title = "Stirling-PDF Survey" [swagger] @@ -7603,6 +7289,28 @@ removeError = "Failed to remove member" renameError = "Failed to rename team" renameSuccess = "Team renamed successfully" +[team.features] +badge = "Team Features" +subtitle = "Collaborate with your team" +title = "Team Collaboration" +viewPlans = "View Plans" + +[team.features.billing] +description = "Manage billing and subscriptions" +title = "Billing Management" + +[team.features.credits] +description = "Shared credit pool for all members" +title = "Shared Credits" + +[team.features.dashboard] +description = "View team activity and usage" +title = "Team Dashboard" + +[team.features.invite] +description = "Add members to your team" +title = "Invite Members" + [team.invitationBanner] acceptButton = "Accept" message = "has invited you to join" @@ -7624,6 +7332,11 @@ remove = "Remove" roleColumn = "Role" title = "Team Members" +[team.upgrade] +button = "Upgrade to Team" +description = "Unlock team collaboration features" +title = "Upgrade to Team Plan" + [textAlign] center = "Center" left = "Left" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 66b97beb7..c417ef1f6 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -17,9 +17,8 @@ import "@app/styles/index.css"; // Import file ID debugging helpers (development only) import "@app/utils/fileIdSafety"; -// Minimal providers for the public, no-auth mobile-scanner page - no API -// calls, no authentication -function PublicRouteProviders({ children }: { children: React.ReactNode }) { +// Minimal providers for mobile scanner - no API calls, no authentication +function MobileScannerProviders({ children }: { children: React.ReactNode }) { return ( {children} @@ -35,9 +34,9 @@ export default function App() { + - + } /> diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index ee3314606..8f9a44f52 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -14,14 +14,6 @@ export interface AuthContextType { * should treat the resulting string as opaque display text. */ displayName: string | null; - /** - * Whether the current session is an anonymous / guest one. Each layer - * derives this from its own native user shape (Supabase `is_anonymous` in - * SaaS, the Spring anonymous flag in proprietary). Always `false` in core - * OSS, which has no auth context. Consumers use it to gate account-only - * actions (cloud folders, MCP) without reaching into a layer-specific user. - */ - isAnonymous: boolean; loading: boolean; error: Error | null; signOut: () => Promise; @@ -37,7 +29,6 @@ export function useAuth(): AuthContextType { session: null, user: null, displayName: null, - isAnonymous: false, loading: false, error: null, signOut: async () => {}, diff --git a/frontend/editor/src/core/components/agents/AgentsPanel.tsx b/frontend/editor/src/core/components/agents/AgentsPanel.tsx new file mode 100644 index 000000000..d5aeee493 --- /dev/null +++ b/frontend/editor/src/core/components/agents/AgentsPanel.tsx @@ -0,0 +1,43 @@ +/** + * Core stubs for the right-rail Agents UI. + * + * The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx} + * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary + * build is active. Core builds render nothing, so the right rail collapses to the + * tool list unchanged. + */ + +/** Whether the right rail should reserve space for agents UI. False in core. */ +export function useAgentsEnabled(): boolean { + return false; +} + +/** + * Whether the agent chat panel is currently open. Core builds have no chat, + * so this always returns false. Proprietary builds bridge to the ChatContext. + */ +export function useAgentChatOpen(): boolean { + return false; +} + +/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */ +export function AgentsSection() { + return null; +} + +/** + * Icon-only agent button rendered in the collapsed (minimised) right rail. + * Returns null in core; proprietary renders the Stirling agent shortcut. + */ +export function AgentsCollapsedButton(_props: { onExpand: () => void }) { + return null; +} + +/** + * Agents card rendered inside the fullscreen tool picker. Matches the visual + * language of the fullscreen category cards (gradient border, title, items). + * Returns null in core; proprietary renders the Stirling agent. + */ +export function AgentsFullscreenSection() { + return null; +} diff --git a/frontend/editor/src/core/components/chat/ChatContext.tsx b/frontend/editor/src/core/components/chat/ChatContext.tsx index cd8f08072..ce47df190 100644 --- a/frontend/editor/src/core/components/chat/ChatContext.tsx +++ b/frontend/editor/src/core/components/chat/ChatContext.tsx @@ -7,9 +7,12 @@ export function useChat() { return { messages: [] as never[], + isOpen: false, isLoading: false, progress: null, progressLog: [] as never[], + toggleOpen: () => {}, + setOpen: (_open: boolean) => {}, sendMessage: async (_content: string) => {}, cancelMessage: () => {}, clearChat: () => {}, diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx index ed8944fb0..3ddd5ca40 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx @@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard"; import FilePickerModal from "@app/components/shared/FilePickerModal"; import { FileId, StirlingFile } from "@app/types/fileContext"; import { alert } from "@app/components/toast"; -import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; +import { downloadFile } from "@app/services/downloadService"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { @@ -256,7 +256,6 @@ const FileEditor = ({ data: file, filename: file.name, localPath: record.localFilePath, - fileId, }); console.log("[FileEditor] Download complete, checking dirty state:", { localFilePath: record.localFilePath, diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 632d9ea00..0b78d4e26 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain"; import HoverActionMenu, { HoverAction, } from "@app/components/shared/HoverActionMenu"; -import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; +import { downloadFile } from "@app/services/downloadService"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import UploadToServerModal from "@app/components/shared/UploadToServerModal"; import ShareFileModal from "@app/components/shared/ShareFileModal"; @@ -248,7 +248,6 @@ const FileEditorThumbnail = ({ data: fileToSave, filename: file.name, localPath: file.localFilePath, - fileId: file.id, }); if (!result.cancelled && result.savedPath) { fileActions.updateStirlingFileStub(file.id, { diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 46644b1b7..6249565ff 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import FolderIcon from "@mui/icons-material/Folder"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; @@ -18,7 +17,6 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import { FileId } from "@app/types/file"; import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; import { useFolders } from "@app/contexts/FolderContext"; -import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { @@ -574,31 +572,6 @@ function FolderCard({ ); } -/** Shield badges for the policies that have run on a file. */ -function PolicyBadges({ fileId }: { fileId: string }) { - const badges = usePolicyFileBadges().get(fileId) ?? []; - if (badges.length === 0) return null; - return ( - - {badges.slice(0, 3).map((policy) => ( - - - - - - ))} - - ); -} - interface FileCardProps { file: StirlingFileStub; isSelected: boolean; @@ -758,7 +731,6 @@ function FileCard({ {fileSize} · {fileDate} -
@@ -1343,7 +1315,6 @@ function FileRow({ )} - {isInWorkspace && ( diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 319871006..8b54960e3 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -34,8 +34,6 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; -import { stripBasePath } from "@app/constants/app"; -import { useAuth } from "@app/auth/UseSession"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useFolders } from "@app/contexts/FolderContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; @@ -108,27 +106,17 @@ export default function FileManagerView() { const isMobile = useIsMobile(); const isMobileUploadAvailable = Boolean(appConfig?.enableMobileScanner) && !isMobile; - // Guests (anonymous sessions) have no server-side storage, so every cloud - // action is account-only. Rather than let the click fire a guaranteed 401 - // (which surfaced as an error toast), we disable the control and explain why - // on hover - the same affordance the storage-disabled / wrong-tab gates use. - const { isAnonymous } = useAuth(); - const signInRequiredReason = isAnonymous - ? t("filesPage.signInRequired", "Sign in to use cloud storage.") - : null; // Server storage gate; mirrors ConfigController's storageEnabled // (enableLogin && storage.isEnabled). When off, Save-to-server stays // visible but disabled with an explanatory tooltip (discoverability beats // hiding - mirrors the New folder / Manage sharing gates in this view). const uploadEnabled = appConfig?.storageEnabled === true; - const saveToServerDisabledReason: string | null = - signInRequiredReason ?? - (uploadEnabled - ? null - : t( - "filesPage.saveToServerDisabledHint", - "Saving to the server isn't enabled on this server. Ask your admin to enable it.", - )); + const saveToServerDisabledReason: string | null = uploadEnabled + ? null + : t( + "filesPage.saveToServerDisabledHint", + "Saving to the server isn't enabled on this server. Ask your admin to enable it.", + ); const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false); const { actions: navActions } = useNavigationActions(); const { requestNavigation } = useNavigationGuard(); @@ -202,11 +190,10 @@ export default function FileManagerView() { // Push folder selection into the URL while still on /files. useEffect(() => { - const stripped = stripBasePath(window.location.pathname); - if (!stripped.startsWith("/files")) return; + if (!window.location.pathname.startsWith("/files")) return; const target = currentFolderId === null ? "/files" : `/files/${currentFolderId}`; - if (stripped !== target) { + if (window.location.pathname !== target) { navigate(target, { replace: true }); } }, [currentFolderId, navigate]); @@ -816,11 +803,6 @@ export default function FileManagerView() { // null = New folder actionable; string = disabled tooltip reason. const newFolderDisabledReason: string | null = useMemo(() => { - // Guests can't use cloud folders at all - say so before any tab/storage - // hint, since switching tabs wouldn't help them. - if (signInRequiredReason) { - return signInRequiredReason; - } if (currentTab === "local") { return t( "filesPage.localFoldersUnavailable", @@ -844,7 +826,7 @@ export default function FileManagerView() { ); } return null; - }, [signInRequiredReason, currentTab, folders.serverReachable, t]); + }, [currentTab, folders.serverReachable, t]); return (
@@ -911,17 +893,14 @@ export default function FileManagerView() { />
+ +